credencegraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- credencegraph/__init__.py +7 -0
- credencegraph/__main__.py +8 -0
- credencegraph/cli/__init__.py +1 -0
- credencegraph/cli/common.py +505 -0
- credencegraph/cli/diagnose.py +176 -0
- credencegraph/cli/edit.py +246 -0
- credencegraph/cli/main.py +101 -0
- credencegraph/cli/query.py +534 -0
- credencegraph/cli/version.py +12 -0
- credencegraph/core/__init__.py +54 -0
- credencegraph/core/anchor.py +49 -0
- credencegraph/core/attributes.py +83 -0
- credencegraph/core/credence.py +177 -0
- credencegraph/core/errors.py +32 -0
- credencegraph/core/graph.py +169 -0
- credencegraph/core/node.py +61 -0
- credencegraph/core/relation.py +89 -0
- credencegraph/core/schema/graph-1.schema.json +109 -0
- credencegraph/core/serialization.py +280 -0
- credencegraph/diagnostics/__init__.py +54 -0
- credencegraph/diagnostics/claims.py +135 -0
- credencegraph/diagnostics/records.py +91 -0
- credencegraph/diagnostics/report.py +71 -0
- credencegraph/diagnostics/structure.py +92 -0
- credencegraph/diagnostics/weak_points.py +379 -0
- credencegraph/inference/__init__.py +41 -0
- credencegraph/inference/elimination.py +155 -0
- credencegraph/inference/engine.py +154 -0
- credencegraph/inference/enumeration.py +63 -0
- credencegraph/inference/errors.py +36 -0
- credencegraph/inference/queries.py +218 -0
- credencegraph/inference/uncertainty.py +191 -0
- credencegraph/semantics/__init__.py +32 -0
- credencegraph/semantics/compiler.py +279 -0
- credencegraph/semantics/cpt.py +108 -0
- credencegraph/semantics/errors.py +13 -0
- credencegraph/semantics/network.py +321 -0
- credencegraph/utils/__init__.py +7 -0
- credencegraph/utils/log.py +70 -0
- credencegraph/version.py +11 -0
- credencegraph-0.1.0.dist-info/METADATA +135 -0
- credencegraph-0.1.0.dist-info/RECORD +45 -0
- credencegraph-0.1.0.dist-info/WHEEL +4 -0
- credencegraph-0.1.0.dist-info/entry_points.txt +2 -0
- credencegraph-0.1.0.dist-info/licenses/LICENSE +26 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command line interface package for credencegraph."""
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Shared pieces of the command line: argument parsing, graph files, and the JSON or text response.
|
|
2
|
+
|
|
3
|
+
Every command takes ``--json``. With it, the command prints exactly one JSON object on stdout, on
|
|
4
|
+
success and on failure alike: ``{"command": ..., ...}`` or ``{"command": ..., "error": {...}}``.
|
|
5
|
+
Without it, a success prints a short summary on stdout and a failure prints ``error: ...`` and a
|
|
6
|
+
hint on stderr. A failure exits with status 1 in both modes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Callable, Iterable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, Any, NoReturn
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from credencegraph.core.anchor import SourceAnchor
|
|
23
|
+
from credencegraph.core.credence import Beta, Credence, Point
|
|
24
|
+
from credencegraph.core.errors import CredenceGraphError, CycleError, ValidationError
|
|
25
|
+
from credencegraph.core.graph import Graph
|
|
26
|
+
from credencegraph.core.serialization import credence_to_json, dumps, loads
|
|
27
|
+
from credencegraph.diagnostics.structure import missing_parameters
|
|
28
|
+
from credencegraph.inference.errors import ProblemTooLargeError, ZeroProbabilityError
|
|
29
|
+
from credencegraph.semantics.compiler import compile_graph, inference_sets
|
|
30
|
+
from credencegraph.semantics.errors import CompileError
|
|
31
|
+
from credencegraph.semantics.network import Network
|
|
32
|
+
|
|
33
|
+
# Error codes. They are part of the JSON output and stay stable across releases.
|
|
34
|
+
FILE_EXISTS = "file-exists"
|
|
35
|
+
FILE_NOT_FOUND = "file-not-found"
|
|
36
|
+
IO_ERROR = "io-error"
|
|
37
|
+
INVALID_GRAPH = "invalid-graph"
|
|
38
|
+
INVALID_ARGUMENT = "invalid-argument"
|
|
39
|
+
DUPLICATE_ID = "duplicate-id"
|
|
40
|
+
UNKNOWN_NODE = "unknown-node"
|
|
41
|
+
CYCLE = "cycle"
|
|
42
|
+
COMPILE_ERROR = "compile-error"
|
|
43
|
+
ZERO_PROBABILITY = "zero-probability"
|
|
44
|
+
PROBLEM_TOO_LARGE = "problem-too-large"
|
|
45
|
+
|
|
46
|
+
ERROR_CODES = (
|
|
47
|
+
FILE_EXISTS,
|
|
48
|
+
FILE_NOT_FOUND,
|
|
49
|
+
IO_ERROR,
|
|
50
|
+
INVALID_GRAPH,
|
|
51
|
+
INVALID_ARGUMENT,
|
|
52
|
+
DUPLICATE_ID,
|
|
53
|
+
UNKNOWN_NODE,
|
|
54
|
+
CYCLE,
|
|
55
|
+
COMPILE_ERROR,
|
|
56
|
+
ZERO_PROBABILITY,
|
|
57
|
+
PROBLEM_TOO_LARGE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Why no world satisfies the exclusive relations. If every base and strength lies strictly between 0
|
|
61
|
+
# and 1, the world in which every proposition is false has positive probability and satisfies every
|
|
62
|
+
# exclusive relation, so a probability of zero needs a parameter at exactly 0 or 1.
|
|
63
|
+
IMPOSSIBLE_GRAPH_HINT = (
|
|
64
|
+
"no world satisfies every exclusive relation under the graph's credences, which needs some base or "
|
|
65
|
+
"strength of exactly 0 or 1; soften one of those or remove an exclusive relation"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
CREDENCE_FORMS = "a probability such as 0.3, or beta:ALPHA,BETA such as beta:8,2"
|
|
69
|
+
|
|
70
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Print one JSON object on stdout instead of text.")]
|
|
71
|
+
GraphPath = Annotated[Path, typer.Argument(help="The graph file.", show_default=False)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CliError(Exception):
|
|
76
|
+
"""A failure to report to the caller.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
code: A stable identifier of the kind of failure, such as ``"unknown-node"``.
|
|
80
|
+
message: What went wrong.
|
|
81
|
+
hint: What to do about it, if there is a single obvious remedy.
|
|
82
|
+
details: Structured data behind the failure, such as the node ids around a cycle.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
code: str
|
|
86
|
+
message: str
|
|
87
|
+
hint: str | None = None
|
|
88
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
def __str__(self) -> str:
|
|
91
|
+
"""Return the message."""
|
|
92
|
+
return self.message
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, Any]:
|
|
95
|
+
"""Return the error as JSON-ready data.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``{"code", "message", "hint", "details"}``.
|
|
99
|
+
"""
|
|
100
|
+
return {"code": self.code, "message": self.message, "hint": self.hint, "details": self.details}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Result:
|
|
105
|
+
"""The outcome of a successful command.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
payload: The JSON fields of the response, besides ``command``.
|
|
109
|
+
text: The summary printed without ``--json``, one line per entry.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
payload: dict[str, Any]
|
|
113
|
+
text: list[str]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def translate(error: CredenceGraphError, command: str | None = None) -> CliError:
|
|
117
|
+
"""Turn a library error into a ``CliError`` with a code and a remedy.
|
|
118
|
+
|
|
119
|
+
Commands check their arguments before calling the library, and explain a graph that cannot be
|
|
120
|
+
compiled with ``compile_failure``, so the last branch is a safety net for a validation error
|
|
121
|
+
no command anticipated.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
error: The library error.
|
|
125
|
+
command: The command that raised it, named in the fallback hint.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The error to report.
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(error, CycleError):
|
|
131
|
+
return CliError(
|
|
132
|
+
CYCLE,
|
|
133
|
+
str(error),
|
|
134
|
+
"requires, supports and refutes relations must not form a cycle; drop or reverse one of the relations named",
|
|
135
|
+
{"cycle": list(error.cycle), "relations": list(error.relations)},
|
|
136
|
+
)
|
|
137
|
+
if isinstance(error, ZeroProbabilityError):
|
|
138
|
+
return CliError(ZERO_PROBABILITY, str(error), IMPOSSIBLE_GRAPH_HINT)
|
|
139
|
+
if isinstance(error, ProblemTooLargeError):
|
|
140
|
+
return CliError(
|
|
141
|
+
PROBLEM_TOO_LARGE,
|
|
142
|
+
str(error),
|
|
143
|
+
"reduce how many relations meet at one node, for example by merging related premises into one; "
|
|
144
|
+
"from Python, raise max_factor_size on VariableElimination instead",
|
|
145
|
+
{"required": error.required, "limit": error.limit},
|
|
146
|
+
)
|
|
147
|
+
usage = f"'credencegraph {command} --help'" if command else "'credencegraph --help'"
|
|
148
|
+
return CliError(INVALID_ARGUMENT, str(error), f"check the arguments against {usage}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def format_credence(credence: Credence) -> str:
|
|
152
|
+
"""Write a credence the way it is typed on the command line.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
credence: The credence.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
``0.3`` for a ``Point``, ``beta:8,2`` for a ``Beta``.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def number(value: float) -> str:
|
|
162
|
+
return str(int(value)) if value.is_integer() else repr(value)
|
|
163
|
+
|
|
164
|
+
if isinstance(credence, Point):
|
|
165
|
+
return number(credence.p)
|
|
166
|
+
return f"beta:{number(credence.alpha)},{number(credence.beta)}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def compile_failure(graph: Graph, error: CompileError) -> CliError:
|
|
170
|
+
"""Explain why a graph cannot be compiled, from the graph itself rather than the message.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
graph: The graph.
|
|
174
|
+
error: The compiler's error, reported as is when the graph shows no missing or conflicting base.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A ``compile-error`` naming the nodes without a base and the equivalent nodes whose bases
|
|
178
|
+
conflict, with ``details.errors`` (the missing-parameter findings) and
|
|
179
|
+
``details.conflicting_bases``.
|
|
180
|
+
"""
|
|
181
|
+
missing = missing_parameters(graph)
|
|
182
|
+
conflicts = []
|
|
183
|
+
for ids in inference_sets(graph).values():
|
|
184
|
+
carriers = [node_id for node_id in ids if graph.nodes[node_id].base is not None]
|
|
185
|
+
conflicts.extend(
|
|
186
|
+
(carriers[0], other) for other in carriers[1:] if graph.nodes[other].base != graph.nodes[carriers[0]].base
|
|
187
|
+
)
|
|
188
|
+
details = {
|
|
189
|
+
"errors": [finding.to_dict() for finding in missing],
|
|
190
|
+
"conflicting_bases": [
|
|
191
|
+
{"nodes": [a, b], "bases": [credence_to_json(graph.nodes[a].base), credence_to_json(graph.nodes[b].base)]} # type: ignore[arg-type]
|
|
192
|
+
for a, b in conflicts
|
|
193
|
+
],
|
|
194
|
+
}
|
|
195
|
+
messages = []
|
|
196
|
+
hints = []
|
|
197
|
+
if conflicts:
|
|
198
|
+
messages.extend(
|
|
199
|
+
f"equivalent nodes {a!r} and {b!r} have conflicting bases "
|
|
200
|
+
f"{format_credence(graph.nodes[a].base)} and {format_credence(graph.nodes[b].base)}" # type: ignore[arg-type]
|
|
201
|
+
for a, b in conflicts
|
|
202
|
+
)
|
|
203
|
+
hints.append("give equivalent nodes the same base, or a base on only one of them")
|
|
204
|
+
if missing:
|
|
205
|
+
nodes = ", ".join(repr(finding.nodes[0]) for finding in missing)
|
|
206
|
+
messages.append(f"inference variables without a base: {nodes}")
|
|
207
|
+
hints.append("set a base on each node listed in the graph file; there is no default credence")
|
|
208
|
+
if not messages:
|
|
209
|
+
return CliError(
|
|
210
|
+
COMPILE_ERROR,
|
|
211
|
+
str(error),
|
|
212
|
+
"an equivalent relation merges nodes that the relations named then join in a cycle; "
|
|
213
|
+
"remove the equivalent relation or one relation of the cycle",
|
|
214
|
+
details,
|
|
215
|
+
)
|
|
216
|
+
return CliError(COMPILE_ERROR, "; ".join(messages), "; ".join(hints), details)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compile_checked(graph: Graph) -> Network:
|
|
220
|
+
"""Compile a graph, explaining a failure with ``compile_failure``.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
graph: The graph.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The network.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
CliError: If the graph cannot be compiled.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
return compile_graph(graph)
|
|
233
|
+
except CompileError as error:
|
|
234
|
+
raise compile_failure(graph, error) from None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def respond(command: str, as_json: bool, action: Callable[[], Result]) -> NoReturn:
|
|
238
|
+
"""Run a command's action and print its result or its error.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
command: The command's name, echoed in the JSON output.
|
|
242
|
+
as_json: Whether to print JSON instead of text.
|
|
243
|
+
action: The work of the command.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
typer.Exit: With status 0 on success, or 1 on an error.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
result = action()
|
|
250
|
+
except CliError as error:
|
|
251
|
+
_fail(command, as_json, error)
|
|
252
|
+
except CredenceGraphError as error:
|
|
253
|
+
_fail(command, as_json, translate(error, command))
|
|
254
|
+
if as_json:
|
|
255
|
+
typer.echo(json.dumps({"command": command, **result.payload}, indent=2, allow_nan=False))
|
|
256
|
+
else:
|
|
257
|
+
for line in result.text:
|
|
258
|
+
typer.echo(line)
|
|
259
|
+
raise typer.Exit(0)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _fail(command: str, as_json: bool, error: CliError) -> NoReturn:
|
|
263
|
+
"""Print an error and exit with status 1.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
command: The command's name.
|
|
267
|
+
as_json: Whether to print JSON instead of text.
|
|
268
|
+
error: The error.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
typer.Exit: Always, with status 1.
|
|
272
|
+
"""
|
|
273
|
+
if as_json:
|
|
274
|
+
typer.echo(json.dumps({"command": command, "error": error.to_dict()}, indent=2, allow_nan=False))
|
|
275
|
+
else:
|
|
276
|
+
typer.echo(f"error: {error.message}", err=True)
|
|
277
|
+
if error.hint:
|
|
278
|
+
typer.echo(f"hint: {error.hint}", err=True)
|
|
279
|
+
raise typer.Exit(1)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def read_graph(path: Path) -> Graph:
|
|
283
|
+
"""Load a graph file.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
path: The file.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The graph.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
CliError: If the file is missing or unreadable, or is not a valid graph.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
text = path.read_text(encoding="utf-8")
|
|
296
|
+
except FileNotFoundError:
|
|
297
|
+
raise CliError(
|
|
298
|
+
FILE_NOT_FOUND, f"no graph file at {str(path)!r}", f"create one with 'credencegraph init {path}'"
|
|
299
|
+
) from None
|
|
300
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
301
|
+
raise CliError(IO_ERROR, f"cannot read {str(path)!r}: {error}") from None
|
|
302
|
+
try:
|
|
303
|
+
return loads(text)
|
|
304
|
+
except CycleError as error:
|
|
305
|
+
cli_error = translate(error)
|
|
306
|
+
cli_error.code = INVALID_GRAPH
|
|
307
|
+
raise cli_error from None
|
|
308
|
+
except ValidationError as error:
|
|
309
|
+
raise CliError(
|
|
310
|
+
INVALID_GRAPH,
|
|
311
|
+
f"{str(path)!r} is not a valid credencegraph file: {error}",
|
|
312
|
+
"fix the item named in the message; the file was not changed",
|
|
313
|
+
) from None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def write_graph(graph: Graph, path: Path) -> None:
|
|
317
|
+
"""Write a graph file, replacing it in one step so a failure leaves the old file intact.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
graph: The graph.
|
|
321
|
+
path: The destination.
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
CliError: If the file cannot be written.
|
|
325
|
+
"""
|
|
326
|
+
text = dumps(graph) + "\n"
|
|
327
|
+
directory = path.parent
|
|
328
|
+
try:
|
|
329
|
+
handle, temporary = tempfile.mkstemp(dir=directory, prefix=f".{path.name}.", suffix=".tmp")
|
|
330
|
+
except FileNotFoundError as error:
|
|
331
|
+
raise CliError(
|
|
332
|
+
IO_ERROR,
|
|
333
|
+
f"cannot write {str(path)!r}: {error}",
|
|
334
|
+
f"the directory {str(directory)!r} does not exist; create it and run the command again",
|
|
335
|
+
) from None
|
|
336
|
+
except OSError as error:
|
|
337
|
+
raise CliError(IO_ERROR, f"cannot write {str(path)!r}: {error}") from None
|
|
338
|
+
try:
|
|
339
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
340
|
+
stream.write(text)
|
|
341
|
+
Path(temporary).replace(path)
|
|
342
|
+
except OSError as error:
|
|
343
|
+
Path(temporary).unlink(missing_ok=True)
|
|
344
|
+
raise CliError(IO_ERROR, f"cannot write {str(path)!r}: {error}") from None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _numbers(text: str, count: int) -> list[float] | None:
|
|
348
|
+
"""Parse ``count`` comma-separated floats, or return ``None`` if ``text`` is not that."""
|
|
349
|
+
parts = text.split(",")
|
|
350
|
+
if len(parts) != count:
|
|
351
|
+
return None
|
|
352
|
+
try:
|
|
353
|
+
return [float(part) for part in parts]
|
|
354
|
+
except ValueError:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def parse_credence(text: str, option: str) -> Credence:
|
|
359
|
+
"""Parse a credence: a bare probability is a ``Point``, ``beta:A,B`` is a ``Beta``.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
text: The command-line value.
|
|
363
|
+
option: The option it came from, used in the error message.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
The credence.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
CliError: If the value is neither form, or its numbers are out of range.
|
|
370
|
+
"""
|
|
371
|
+
kind, colon, rest = text.partition(":")
|
|
372
|
+
try:
|
|
373
|
+
if not colon:
|
|
374
|
+
try:
|
|
375
|
+
return Point(float(text))
|
|
376
|
+
except ValueError:
|
|
377
|
+
pass
|
|
378
|
+
elif kind.strip().lower() == "beta":
|
|
379
|
+
numbers = _numbers(rest, 2)
|
|
380
|
+
if numbers is not None:
|
|
381
|
+
return Beta(*numbers)
|
|
382
|
+
except ValidationError as error:
|
|
383
|
+
raise CliError(INVALID_ARGUMENT, f"{option} {text!r}: {error}", f"{option} takes {CREDENCE_FORMS}") from None
|
|
384
|
+
raise CliError(INVALID_ARGUMENT, f"{option} {text!r} is not a credence", f"{option} takes {CREDENCE_FORMS}")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def parse_source(text: str) -> SourceAnchor:
|
|
388
|
+
"""Parse a source anchor, ``DOCUMENT`` or ``DOCUMENT#LOCATOR``, splitting at the last ``#``.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
text: The command-line value.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
The anchor.
|
|
395
|
+
|
|
396
|
+
Raises:
|
|
397
|
+
CliError: If the document or the locator is empty.
|
|
398
|
+
"""
|
|
399
|
+
document, hash_sign, locator = text.rpartition("#")
|
|
400
|
+
if not hash_sign:
|
|
401
|
+
document, locator = text, ""
|
|
402
|
+
if not document or (hash_sign and not locator):
|
|
403
|
+
raise CliError(
|
|
404
|
+
INVALID_ARGUMENT,
|
|
405
|
+
f"--source {text!r} needs a document{' and a locator after #' if hash_sign else ''}",
|
|
406
|
+
"--source takes DOCUMENT or DOCUMENT#LOCATOR, such as doi:10.1000/xyz#p4",
|
|
407
|
+
)
|
|
408
|
+
return SourceAnchor(document, locator or None)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
_TRUE = {"true", "1", "yes"}
|
|
412
|
+
_FALSE = {"false", "0", "no"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def parse_assignment(items: Iterable[str], option: str, *, default: bool | None = None) -> dict[str, bool]:
|
|
416
|
+
"""Parse ``NODE=true`` / ``NODE=false`` items into an assignment.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
items: The command-line values.
|
|
420
|
+
option: The option or argument they came from, used in error messages.
|
|
421
|
+
default: The value of an item given as a bare ``NODE``; ``None`` makes the value mandatory.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Node ids and values, in the order given.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
CliError: If an item is malformed or one node is given both values.
|
|
428
|
+
"""
|
|
429
|
+
form = "NODE or NODE=true|false" if default is not None else "NODE=true|false"
|
|
430
|
+
assignment: dict[str, bool] = {}
|
|
431
|
+
for item in items:
|
|
432
|
+
node_id, equals, raw = item.rpartition("=")
|
|
433
|
+
if not equals:
|
|
434
|
+
node_id, raw = item, ""
|
|
435
|
+
if not equals and default is not None and node_id:
|
|
436
|
+
value = default
|
|
437
|
+
elif node_id and raw.strip().lower() in _TRUE:
|
|
438
|
+
value = True
|
|
439
|
+
elif node_id and raw.strip().lower() in _FALSE:
|
|
440
|
+
value = False
|
|
441
|
+
else:
|
|
442
|
+
raise CliError(
|
|
443
|
+
INVALID_ARGUMENT, f"{option} {item!r} is not {form}", f"write it as {form}, such as h1=false"
|
|
444
|
+
)
|
|
445
|
+
if assignment.get(node_id, value) != value:
|
|
446
|
+
raise CliError(INVALID_ARGUMENT, f"{option} gives {node_id!r} both true and false")
|
|
447
|
+
assignment[node_id] = value
|
|
448
|
+
return assignment
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def require_nodes(graph: Graph, node_ids: Iterable[str], role: str) -> None:
|
|
452
|
+
"""Check that every id names a node of the graph.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
graph: The graph.
|
|
456
|
+
node_ids: The ids to check.
|
|
457
|
+
role: What the ids are for, used in the error message.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
CliError: If an id is not a node, suggesting the closest ones that are.
|
|
461
|
+
"""
|
|
462
|
+
for node_id in node_ids:
|
|
463
|
+
if node_id in graph:
|
|
464
|
+
continue
|
|
465
|
+
close = difflib.get_close_matches(node_id, list(graph.nodes), n=3)
|
|
466
|
+
hint = f"did you mean {', '.join(repr(c) for c in close)}?" if close else "add it with 'credencegraph add-node'"
|
|
467
|
+
raise CliError(UNKNOWN_NODE, f"{role} {node_id!r} is not a node of the graph", hint, {"node": node_id})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def require_variables(graph: Graph, node_ids: Iterable[str], role: str) -> None:
|
|
471
|
+
"""Check that every id names a node of the graph that takes part in inference.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
graph: The graph.
|
|
475
|
+
node_ids: The ids to check.
|
|
476
|
+
role: What the ids are for, such as ``--given``, used in the error message.
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
CliError: If an id is not a node, or is a node with no base and no inferential relation.
|
|
480
|
+
"""
|
|
481
|
+
node_ids = list(node_ids)
|
|
482
|
+
require_nodes(graph, node_ids, role)
|
|
483
|
+
variables = {node_id for ids in inference_sets(graph).values() for node_id in ids}
|
|
484
|
+
for node_id in node_ids:
|
|
485
|
+
if node_id not in variables:
|
|
486
|
+
raise CliError(
|
|
487
|
+
INVALID_ARGUMENT,
|
|
488
|
+
f"{role} {node_id!r} takes no part in inference: it has no base and no inferential relation",
|
|
489
|
+
f"give {node_id!r} a base or an inferential relation in the graph file, or name another node",
|
|
490
|
+
{"node": node_id},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def require_text(value: str | None, option: str) -> None:
|
|
495
|
+
"""Check that an option given on the command line is not empty.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
value: The value, or ``None`` when the option was not given.
|
|
499
|
+
option: The option, used in the error message.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
CliError: If the value is the empty string.
|
|
503
|
+
"""
|
|
504
|
+
if value is not None and not value:
|
|
505
|
+
raise CliError(INVALID_ARGUMENT, f"{option} must not be empty", f"pass a non-empty {option}")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The ``diagnose`` and ``check`` commands: weak points of a target, and whether a graph file is usable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from credencegraph.cli.common import (
|
|
11
|
+
INVALID_ARGUMENT,
|
|
12
|
+
PROBLEM_TOO_LARGE,
|
|
13
|
+
CliError,
|
|
14
|
+
GraphPath,
|
|
15
|
+
JsonOption,
|
|
16
|
+
Result,
|
|
17
|
+
compile_failure,
|
|
18
|
+
read_graph,
|
|
19
|
+
require_variables,
|
|
20
|
+
respond,
|
|
21
|
+
)
|
|
22
|
+
from credencegraph.diagnostics.claims import DEFAULT_CLAIM_THRESHOLD
|
|
23
|
+
from credencegraph.diagnostics.records import Finding
|
|
24
|
+
from credencegraph.diagnostics.report import diagnose
|
|
25
|
+
from credencegraph.diagnostics.structure import unanchored
|
|
26
|
+
from credencegraph.diagnostics.weak_points import DEFAULT_FAILURE_THRESHOLD
|
|
27
|
+
from credencegraph.inference.elimination import DEFAULT_MAX_FACTOR_SIZE, VariableElimination
|
|
28
|
+
from credencegraph.inference.errors import ProblemTooLargeError
|
|
29
|
+
from credencegraph.semantics.compiler import compile_graph
|
|
30
|
+
from credencegraph.semantics.errors import CompileError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _lines(findings: list[Finding]) -> list[str]:
|
|
34
|
+
"""Render findings one per line.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
findings: The findings.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
``<id>: <message>`` for each finding.
|
|
41
|
+
"""
|
|
42
|
+
return [f"{finding.id}: {finding.message}" for finding in findings]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _threshold(value: float, option: str) -> float:
|
|
46
|
+
"""Check that a threshold is a number in [0, 1].
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value: The value given on the command line.
|
|
50
|
+
option: The option it came from, used in the error message.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The value.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
CliError: If the value is not in [0, 1].
|
|
57
|
+
"""
|
|
58
|
+
if not 0.0 <= value <= 1.0:
|
|
59
|
+
raise CliError(
|
|
60
|
+
INVALID_ARGUMENT,
|
|
61
|
+
f"{option} must lie in [0, 1], got {value!r}",
|
|
62
|
+
f"pass {option} a number in [0, 1]",
|
|
63
|
+
)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _log_odds_threshold(value: float, option: str) -> float:
|
|
68
|
+
"""Check that a threshold is a finite gap in log-odds.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
value: The value given on the command line.
|
|
72
|
+
option: The option it came from, used in the error message.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The value.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
CliError: If the value is negative, infinite or not a number.
|
|
79
|
+
"""
|
|
80
|
+
if not 0.0 <= value < math.inf:
|
|
81
|
+
raise CliError(
|
|
82
|
+
INVALID_ARGUMENT,
|
|
83
|
+
f"{option} must be a finite number >= 0, got {value!r}",
|
|
84
|
+
f"pass {option} a gap in natural log-odds, such as 0.69 for a factor of 2 in the odds",
|
|
85
|
+
)
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def diagnose_command( # noqa: PLR0913, PLR0917 - Typer maps one parameter to each option
|
|
90
|
+
path: GraphPath,
|
|
91
|
+
target: Annotated[
|
|
92
|
+
str | None,
|
|
93
|
+
typer.Option(help="The node whose weak points are wanted; without it only the graph-wide checks run."),
|
|
94
|
+
] = None,
|
|
95
|
+
claim_threshold: Annotated[
|
|
96
|
+
float,
|
|
97
|
+
typer.Option(
|
|
98
|
+
help="Report |logit(stated) - logit(computed)| above this, in natural log-odds, as an overclaim or underclaim."
|
|
99
|
+
),
|
|
100
|
+
] = DEFAULT_CLAIM_THRESHOLD,
|
|
101
|
+
failure_threshold: Annotated[
|
|
102
|
+
float,
|
|
103
|
+
typer.Option(
|
|
104
|
+
help="Report a premise whose failure leaves the target below this fraction of its own probability."
|
|
105
|
+
),
|
|
106
|
+
] = DEFAULT_FAILURE_THRESHOLD,
|
|
107
|
+
max_factor_size: Annotated[
|
|
108
|
+
int, typer.Option(help="The largest intermediate factor exact inference may build, in table entries.")
|
|
109
|
+
] = DEFAULT_MAX_FACTOR_SIZE,
|
|
110
|
+
as_json: JsonOption = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Report the weak points of a graph file and, with --target, of one of its nodes."""
|
|
113
|
+
|
|
114
|
+
def action() -> Result:
|
|
115
|
+
claims_at = _log_odds_threshold(claim_threshold, "--claim-threshold")
|
|
116
|
+
failures_at = _threshold(failure_threshold, "--failure-threshold")
|
|
117
|
+
if max_factor_size < 1:
|
|
118
|
+
raise CliError(
|
|
119
|
+
INVALID_ARGUMENT,
|
|
120
|
+
f"--max-factor-size must be 1 or more, got {max_factor_size}",
|
|
121
|
+
f"the default is {DEFAULT_MAX_FACTOR_SIZE} table entries",
|
|
122
|
+
)
|
|
123
|
+
graph = read_graph(path)
|
|
124
|
+
if target is not None:
|
|
125
|
+
require_variables(graph, (target,), "--target")
|
|
126
|
+
try:
|
|
127
|
+
findings = diagnose(
|
|
128
|
+
graph,
|
|
129
|
+
target,
|
|
130
|
+
claim_threshold=claims_at,
|
|
131
|
+
failure_threshold=failures_at,
|
|
132
|
+
engine=VariableElimination(max_factor_size),
|
|
133
|
+
)
|
|
134
|
+
except CompileError as error:
|
|
135
|
+
raise compile_failure(graph, error) from None
|
|
136
|
+
except ProblemTooLargeError as error:
|
|
137
|
+
raise CliError(
|
|
138
|
+
PROBLEM_TOO_LARGE,
|
|
139
|
+
str(error),
|
|
140
|
+
f"the graph is too large for exact inference at this limit; raise it with --max-factor-size, "
|
|
141
|
+
f"to at least {error.required}",
|
|
142
|
+
{"required": error.required, "limit": error.limit},
|
|
143
|
+
) from None
|
|
144
|
+
payload = {"path": str(path), "target": target, "findings": [finding.to_dict() for finding in findings]}
|
|
145
|
+
return Result(payload, _lines(findings) or ["no findings"])
|
|
146
|
+
|
|
147
|
+
respond("diagnose", as_json, action)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_command(path: GraphPath, as_json: JsonOption = False) -> None:
|
|
151
|
+
"""Check that a graph file is valid and can be compiled for inference.
|
|
152
|
+
|
|
153
|
+
The file must parse as a credencegraph graph: known fields, unique ids, relations between existing
|
|
154
|
+
nodes, no cycle among requires, supports and refutes, and no equivalent or exclusive relation
|
|
155
|
+
joining a node to itself. It must also compile: every inference
|
|
156
|
+
variable needs a base, and equivalent nodes need the same one. A graph that does not compile is
|
|
157
|
+
reported as a compile-error, with the counts and every finding under details. An unanchored
|
|
158
|
+
variable is reported as a warning and does not fail the check.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def action() -> Result:
|
|
162
|
+
graph = read_graph(path)
|
|
163
|
+
warnings = unanchored(graph)
|
|
164
|
+
counts = {"path": str(path), "nodes": len(graph.nodes), "relations": len(graph.relations)}
|
|
165
|
+
try:
|
|
166
|
+
compile_graph(graph)
|
|
167
|
+
except CompileError as error:
|
|
168
|
+
failure = compile_failure(graph, error)
|
|
169
|
+
failure.details = {**counts, **failure.details, "warnings": [finding.to_dict() for finding in warnings]}
|
|
170
|
+
raise failure from None
|
|
171
|
+
payload = {**counts, "warnings": [finding.to_dict() for finding in warnings]}
|
|
172
|
+
text = [f"{path}: ok ({len(graph.nodes)} nodes, {len(graph.relations)} relations)"]
|
|
173
|
+
text.extend(f"warning: {line}" for line in _lines(warnings))
|
|
174
|
+
return Result(payload, text)
|
|
175
|
+
|
|
176
|
+
respond("check", as_json, action)
|