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.
Files changed (45) hide show
  1. credencegraph/__init__.py +7 -0
  2. credencegraph/__main__.py +8 -0
  3. credencegraph/cli/__init__.py +1 -0
  4. credencegraph/cli/common.py +505 -0
  5. credencegraph/cli/diagnose.py +176 -0
  6. credencegraph/cli/edit.py +246 -0
  7. credencegraph/cli/main.py +101 -0
  8. credencegraph/cli/query.py +534 -0
  9. credencegraph/cli/version.py +12 -0
  10. credencegraph/core/__init__.py +54 -0
  11. credencegraph/core/anchor.py +49 -0
  12. credencegraph/core/attributes.py +83 -0
  13. credencegraph/core/credence.py +177 -0
  14. credencegraph/core/errors.py +32 -0
  15. credencegraph/core/graph.py +169 -0
  16. credencegraph/core/node.py +61 -0
  17. credencegraph/core/relation.py +89 -0
  18. credencegraph/core/schema/graph-1.schema.json +109 -0
  19. credencegraph/core/serialization.py +280 -0
  20. credencegraph/diagnostics/__init__.py +54 -0
  21. credencegraph/diagnostics/claims.py +135 -0
  22. credencegraph/diagnostics/records.py +91 -0
  23. credencegraph/diagnostics/report.py +71 -0
  24. credencegraph/diagnostics/structure.py +92 -0
  25. credencegraph/diagnostics/weak_points.py +379 -0
  26. credencegraph/inference/__init__.py +41 -0
  27. credencegraph/inference/elimination.py +155 -0
  28. credencegraph/inference/engine.py +154 -0
  29. credencegraph/inference/enumeration.py +63 -0
  30. credencegraph/inference/errors.py +36 -0
  31. credencegraph/inference/queries.py +218 -0
  32. credencegraph/inference/uncertainty.py +191 -0
  33. credencegraph/semantics/__init__.py +32 -0
  34. credencegraph/semantics/compiler.py +279 -0
  35. credencegraph/semantics/cpt.py +108 -0
  36. credencegraph/semantics/errors.py +13 -0
  37. credencegraph/semantics/network.py +321 -0
  38. credencegraph/utils/__init__.py +7 -0
  39. credencegraph/utils/log.py +70 -0
  40. credencegraph/version.py +11 -0
  41. credencegraph-0.1.0.dist-info/METADATA +135 -0
  42. credencegraph-0.1.0.dist-info/RECORD +45 -0
  43. credencegraph-0.1.0.dist-info/WHEEL +4 -0
  44. credencegraph-0.1.0.dist-info/entry_points.txt +2 -0
  45. credencegraph-0.1.0.dist-info/licenses/LICENSE +26 -0
@@ -0,0 +1,7 @@
1
+ """Top-level package for credencegraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from credencegraph.version import __version__
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Main entry point for the credencegraph package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ if __name__ == "__main__":
6
+ from credencegraph.utils.log import setup_logger
7
+
8
+ setup_logger(print_version=True)
@@ -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)