entropy-table 1.0.1__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.
- entropy_table/__init__.py +0 -0
- entropy_table/_version.py +34 -0
- entropy_table/cli.py +147 -0
- entropy_table/commands/__init__.py +0 -0
- entropy_table/commands/analyze_health.py +214 -0
- entropy_table/commands/build_index.py +169 -0
- entropy_table/commands/extract_domain_from_template.py +125 -0
- entropy_table/commands/freeze_guard.py +18 -0
- entropy_table/commands/ingest.py +201 -0
- entropy_table/commands/manage_cases.py +367 -0
- entropy_table/commands/metrics.py +210 -0
- entropy_table/commands/query.py +348 -0
- entropy_table/commands/query_claims.py +131 -0
- entropy_table/commands/release.py +323 -0
- entropy_table/commands/render.py +83 -0
- entropy_table/commands/report_claims.py +127 -0
- entropy_table/commands/scaffold.py +101 -0
- entropy_table/commands/snapshot.py +168 -0
- entropy_table/commands/validate.py +176 -0
- entropy_table/commands/validate_bibliography.py +200 -0
- entropy_table/commands/validate_claims.py +273 -0
- entropy_table/commands/validate_composition.py +397 -0
- entropy_table/commands/validate_math.py +141 -0
- entropy_table/commands/visualize.py +351 -0
- entropy_table/compute/__init__.py +6 -0
- entropy_table/compute/case_runner.py +250 -0
- entropy_table/compute/cli.py +68 -0
- entropy_table/compute/ctmc_ep.py +111 -0
- entropy_table/compute/diffusion_ep_1d.py +47 -0
- entropy_table/compute/report.py +225 -0
- entropy_table/core/__init__.py +0 -0
- entropy_table/core/bindings.py +32 -0
- entropy_table/core/common.py +27 -0
- entropy_table-1.0.1.dist-info/METADATA +124 -0
- entropy_table-1.0.1.dist-info/RECORD +39 -0
- entropy_table-1.0.1.dist-info/WHEEL +5 -0
- entropy_table-1.0.1.dist-info/entry_points.txt +2 -0
- entropy_table-1.0.1.dist-info/licenses/LICENSE +21 -0
- entropy_table-1.0.1.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '1.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
entropy_table/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""entropy-table – Contract-first scientific entropy atlas CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="entropy-table",
|
|
10
|
+
help="Contract-first scientific entropy atlas CLI",
|
|
11
|
+
rich_markup_mode="markdown",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Validation ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def validate(
|
|
20
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable JSON output"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Validate domain/relation schemas and cross-references."""
|
|
23
|
+
from entropy_table.commands.validate import main
|
|
24
|
+
argv = ["--json"] if json_out else []
|
|
25
|
+
raise SystemExit(main(argv))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("validate-all")
|
|
29
|
+
def validate_all() -> None:
|
|
30
|
+
"""Run ALL validation checks (schema + claims + composition + bibliography + math)."""
|
|
31
|
+
from entropy_table.commands.validate import main as validate_main
|
|
32
|
+
from entropy_table.commands.validate_claims import main as claims_main
|
|
33
|
+
from entropy_table.commands.validate_composition import main as comp_main
|
|
34
|
+
from entropy_table.commands.validate_bibliography import main as bib_main
|
|
35
|
+
from entropy_table.commands.manage_cases import main as cases_main
|
|
36
|
+
from entropy_table.commands.validate_math import main as math_main
|
|
37
|
+
|
|
38
|
+
rc = 0
|
|
39
|
+
rc |= validate_main([]) or 0
|
|
40
|
+
rc |= claims_main([]) or 0
|
|
41
|
+
rc |= comp_main([]) or 0
|
|
42
|
+
rc |= bib_main([]) or 0
|
|
43
|
+
rc |= cases_main(["validate"]) or 0
|
|
44
|
+
rc |= math_main([]) or 0
|
|
45
|
+
raise SystemExit(rc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("validate-math")
|
|
49
|
+
def validate_math() -> None:
|
|
50
|
+
"""Validate mathematical expressions in atlas domains (SymPy-assisted)."""
|
|
51
|
+
from entropy_table.commands.validate_math import main
|
|
52
|
+
raise SystemExit(main([]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("validate-cases")
|
|
56
|
+
def validate_cases() -> None:
|
|
57
|
+
"""Validate claim↔case cross-references (dangling + orphaned)."""
|
|
58
|
+
from entropy_table.commands.manage_cases import main
|
|
59
|
+
raise SystemExit(main(["validate"]))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Visualisation ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def visualize(
|
|
66
|
+
format: str = typer.Option("mermaid", "--format", help="mermaid | dot"),
|
|
67
|
+
output: str = typer.Option("docs/atlas_graph.mmd", "--output", help="Output file path"),
|
|
68
|
+
filter_status: list[str] = typer.Option([], "--filter-status", help="Filter by status"),
|
|
69
|
+
exclude_group: list[str] = typer.Option([], "--exclude-group", help="Exclude group"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Generate Mermaid or Graphviz DOT graph."""
|
|
72
|
+
from entropy_table.commands.visualize import main
|
|
73
|
+
argv = ["--format", format, "--output", output]
|
|
74
|
+
for s in filter_status:
|
|
75
|
+
argv += ["--filter-status", s]
|
|
76
|
+
for g in exclude_group:
|
|
77
|
+
argv += ["--exclude-group", g]
|
|
78
|
+
raise SystemExit(main(argv))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Rendering ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def render() -> None:
|
|
85
|
+
"""Render atlas to atlas.md + atlas.tex."""
|
|
86
|
+
from entropy_table.commands.render import main
|
|
87
|
+
raise SystemExit(main())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Scaffolding ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def scaffold(
|
|
94
|
+
kind: str = typer.Argument(..., help="domain | case"),
|
|
95
|
+
id: str = typer.Argument(..., help="kebab-case ID"),
|
|
96
|
+
category: str = typer.Option("01_physics", "--category", help="Target category"),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Scaffold a new domain or case."""
|
|
99
|
+
from entropy_table.commands.scaffold import main
|
|
100
|
+
argv = [kind, id, "--category", category]
|
|
101
|
+
raise SystemExit(main(argv))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Analysis & Metrics ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def health(
|
|
108
|
+
ci_check: bool = typer.Option(False, "--ci-check", help="Exit 1 on integrity issues"),
|
|
109
|
+
out: str = typer.Option("outputs/atlas_health.md", "--out"),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Atlas health analysis (orphaned domains, unfalsifiable claims, …)."""
|
|
112
|
+
from entropy_table.commands.analyze_health import main
|
|
113
|
+
argv = ["--out", out]
|
|
114
|
+
if ci_check:
|
|
115
|
+
argv.append("--ci-check")
|
|
116
|
+
raise SystemExit(main(argv))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def metrics(
|
|
121
|
+
format: str = typer.Option("markdown", "--format", help="markdown | json"),
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Compute operational atlas metrics."""
|
|
124
|
+
from entropy_table.commands.metrics import main
|
|
125
|
+
raise SystemExit(main(["--format", format]))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Index & Release ───────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
@app.command("build-index")
|
|
131
|
+
def build_index() -> None:
|
|
132
|
+
"""Build the domain/relation search index."""
|
|
133
|
+
from entropy_table.commands.build_index import main
|
|
134
|
+
raise SystemExit(main([]))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def release(
|
|
139
|
+
version: str = typer.Option("dev", "--version", help="Release version, e.g. v1.2.3"),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Build a release pack."""
|
|
142
|
+
from entropy_table.commands.release import main
|
|
143
|
+
raise SystemExit(main(["--version", version]))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from entropy_table.core.common import ROOT, load_yaml
|
|
8
|
+
|
|
9
|
+
DEFAULT_REPORT_PATH = Path("outputs/atlas_health.md")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
13
|
+
parser = argparse.ArgumentParser(description="Analyze atlas graph health and coverage gaps")
|
|
14
|
+
parser.add_argument("--atlas-root", default="atlas", help="Atlas root directory")
|
|
15
|
+
parser.add_argument("--out", default=str(DEFAULT_REPORT_PATH), help="Markdown report output path")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--ci-check",
|
|
18
|
+
action="store_true",
|
|
19
|
+
help="Exit with code 1 when stable claims are unfalsifiable or uncited",
|
|
20
|
+
)
|
|
21
|
+
return parser.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_path(path_arg: str, *, base: Path) -> Path:
|
|
25
|
+
path = Path(path_arg)
|
|
26
|
+
if path.is_absolute():
|
|
27
|
+
return path
|
|
28
|
+
return base / path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _yaml_files(root: Path) -> list[Path]:
|
|
32
|
+
if not root.exists():
|
|
33
|
+
return []
|
|
34
|
+
return sorted(root.glob("**/*.yaml"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_items(paths: list[Path]) -> list[dict]:
|
|
38
|
+
return [load_yaml(path) for path in paths]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def analyze_health(atlas_root: Path) -> dict:
|
|
42
|
+
domain_paths = _yaml_files(atlas_root / "domains")
|
|
43
|
+
relation_paths = _yaml_files(atlas_root / "relations")
|
|
44
|
+
claim_paths = _yaml_files(atlas_root / "claims")
|
|
45
|
+
|
|
46
|
+
domains = _load_items(domain_paths)
|
|
47
|
+
relations = _load_items(relation_paths)
|
|
48
|
+
claims = _load_items(claim_paths)
|
|
49
|
+
|
|
50
|
+
domain_ids_to_paths: dict[str, Path] = {}
|
|
51
|
+
for path, domain in zip(domain_paths, domains):
|
|
52
|
+
domain_id = domain.get("id")
|
|
53
|
+
if isinstance(domain_id, str) and domain_id:
|
|
54
|
+
domain_ids_to_paths[domain_id] = path
|
|
55
|
+
|
|
56
|
+
connected_domain_ids: set[str] = set()
|
|
57
|
+
regime_shift_connected_ids: set[str] = set()
|
|
58
|
+
for relation in relations:
|
|
59
|
+
src = relation.get("source_domain_id")
|
|
60
|
+
tgt = relation.get("target_domain_id")
|
|
61
|
+
if isinstance(src, str) and src:
|
|
62
|
+
connected_domain_ids.add(src)
|
|
63
|
+
if isinstance(tgt, str) and tgt:
|
|
64
|
+
connected_domain_ids.add(tgt)
|
|
65
|
+
|
|
66
|
+
if relation.get("relation_type") == "regime_shift":
|
|
67
|
+
if isinstance(src, str) and src:
|
|
68
|
+
regime_shift_connected_ids.add(src)
|
|
69
|
+
if isinstance(tgt, str) and tgt:
|
|
70
|
+
regime_shift_connected_ids.add(tgt)
|
|
71
|
+
|
|
72
|
+
orphaned_domains: list[dict] = []
|
|
73
|
+
missing_regime_shifts: list[dict] = []
|
|
74
|
+
|
|
75
|
+
for domain in domains:
|
|
76
|
+
domain_id = domain.get("id")
|
|
77
|
+
if not isinstance(domain_id, str) or not domain_id:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
domain_path = domain_ids_to_paths.get(domain_id)
|
|
81
|
+
limitations = domain.get("limitations")
|
|
82
|
+
assumptions = domain.get("assumptions")
|
|
83
|
+
has_limits_or_assumptions = (
|
|
84
|
+
isinstance(limitations, list)
|
|
85
|
+
and len([item for item in limitations if isinstance(item, str) and item.strip()]) > 0
|
|
86
|
+
) or (
|
|
87
|
+
isinstance(assumptions, list)
|
|
88
|
+
and len([item for item in assumptions if isinstance(item, str) and item.strip()]) > 0
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if domain_id not in connected_domain_ids:
|
|
92
|
+
orphaned_domains.append({"id": domain_id, "path": domain_path})
|
|
93
|
+
|
|
94
|
+
if has_limits_or_assumptions and domain_id not in regime_shift_connected_ids:
|
|
95
|
+
missing_regime_shifts.append({"id": domain_id, "path": domain_path})
|
|
96
|
+
|
|
97
|
+
unfalsifiable_stable_claims: list[dict] = []
|
|
98
|
+
uncited_stable_claims: list[dict] = []
|
|
99
|
+
|
|
100
|
+
for path, claim in zip(claim_paths, claims):
|
|
101
|
+
if claim.get("status") != "stable":
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
evidence = claim.get("evidence")
|
|
105
|
+
cases: list[str] = []
|
|
106
|
+
citations: list[str] = []
|
|
107
|
+
if isinstance(evidence, dict):
|
|
108
|
+
raw_cases = evidence.get("cases")
|
|
109
|
+
if isinstance(raw_cases, list):
|
|
110
|
+
cases = [case for case in raw_cases if isinstance(case, str) and case.strip()]
|
|
111
|
+
|
|
112
|
+
raw_citations = evidence.get("citations")
|
|
113
|
+
if isinstance(raw_citations, list):
|
|
114
|
+
citations = [citation for citation in raw_citations if isinstance(citation, str) and citation.strip()]
|
|
115
|
+
|
|
116
|
+
row = {"id": claim.get("id", "<missing-id>"), "path": path}
|
|
117
|
+
|
|
118
|
+
if len(cases) == 0:
|
|
119
|
+
unfalsifiable_stable_claims.append(row)
|
|
120
|
+
if len(citations) == 0:
|
|
121
|
+
uncited_stable_claims.append(row)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"total_domains": len(domain_ids_to_paths),
|
|
125
|
+
"total_relations": len(relations),
|
|
126
|
+
"total_claims": len(claims),
|
|
127
|
+
"orphaned_domains": sorted(orphaned_domains, key=lambda item: item["id"]),
|
|
128
|
+
"missing_regime_shifts": sorted(missing_regime_shifts, key=lambda item: item["id"]),
|
|
129
|
+
"unfalsifiable_stable_claims": sorted(unfalsifiable_stable_claims, key=lambda item: str(item["id"])),
|
|
130
|
+
"uncited_stable_claims": sorted(uncited_stable_claims, key=lambda item: str(item["id"])),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render_table(rows: list[dict]) -> list[str]:
|
|
135
|
+
if not rows:
|
|
136
|
+
return ["_None._", ""]
|
|
137
|
+
lines = ["| ID | File |", "| --- | --- |"]
|
|
138
|
+
for row in rows:
|
|
139
|
+
path = row.get("path")
|
|
140
|
+
lines.append(f"| `{row.get('id', '<missing-id>')}` | `{path}` |")
|
|
141
|
+
lines.append("")
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_markdown(report: dict) -> str:
|
|
146
|
+
lines = [
|
|
147
|
+
"# Atlas Graph Health & Coverage Report",
|
|
148
|
+
"",
|
|
149
|
+
"## Summary",
|
|
150
|
+
"",
|
|
151
|
+
f"- Total Domains: {report['total_domains']}",
|
|
152
|
+
f"- Total Relations: {report['total_relations']}",
|
|
153
|
+
f"- Total Claims: {report['total_claims']}",
|
|
154
|
+
f"- Orphaned Domains: {len(report['orphaned_domains'])}",
|
|
155
|
+
f"- Missing Regime Shifts: {len(report['missing_regime_shifts'])}",
|
|
156
|
+
f"- Unfalsifiable Stable Claims: {len(report['unfalsifiable_stable_claims'])}",
|
|
157
|
+
f"- Uncited Stable Claims: {len(report['uncited_stable_claims'])}",
|
|
158
|
+
"",
|
|
159
|
+
"## Orphaned Domains",
|
|
160
|
+
"",
|
|
161
|
+
"Domains not referenced as `source_domain_id` or `target_domain_id` in any relation.",
|
|
162
|
+
"",
|
|
163
|
+
]
|
|
164
|
+
lines.extend(_render_table(report["orphaned_domains"]))
|
|
165
|
+
lines.extend(
|
|
166
|
+
[
|
|
167
|
+
"## Missing Regime Shifts",
|
|
168
|
+
"",
|
|
169
|
+
"Domains with listed `limitations` or `assumptions` but no connected `regime_shift` relation.",
|
|
170
|
+
"",
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
lines.extend(_render_table(report["missing_regime_shifts"]))
|
|
174
|
+
lines.extend(
|
|
175
|
+
[
|
|
176
|
+
"## Unfalsifiable Stable Claims",
|
|
177
|
+
"",
|
|
178
|
+
"Stable claims with no linked `evidence.cases` entries.",
|
|
179
|
+
"",
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
lines.extend(_render_table(report["unfalsifiable_stable_claims"]))
|
|
183
|
+
lines.extend(
|
|
184
|
+
[
|
|
185
|
+
"## Uncited Stable Claims",
|
|
186
|
+
"",
|
|
187
|
+
"Stable claims with missing or empty `evidence.citations`.",
|
|
188
|
+
"",
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
lines.extend(_render_table(report["uncited_stable_claims"]))
|
|
192
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: list[str] | None = None) -> int:
|
|
196
|
+
args = parse_args(argv)
|
|
197
|
+
atlas_root = _resolve_path(args.atlas_root, base=ROOT)
|
|
198
|
+
output_path = _resolve_path(args.out, base=ROOT)
|
|
199
|
+
|
|
200
|
+
report = analyze_health(atlas_root)
|
|
201
|
+
|
|
202
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
output_path.write_text(render_markdown(report), encoding="utf-8")
|
|
204
|
+
print(f"Wrote atlas health report: {output_path}")
|
|
205
|
+
|
|
206
|
+
ci_failures = len(report["unfalsifiable_stable_claims"]) + len(report["uncited_stable_claims"])
|
|
207
|
+
if args.ci_check and ci_failures > 0:
|
|
208
|
+
print("CI check failed: stable claim integrity issues detected.", file=sys.stderr)
|
|
209
|
+
return 1
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Build deterministic atlas metadata index")
|
|
15
|
+
parser.add_argument("--out", default="cache/index.json")
|
|
16
|
+
parser.add_argument("--domains-root", default="atlas/domains")
|
|
17
|
+
parser.add_argument("--relations-root", default="atlas/relations")
|
|
18
|
+
return parser.parse_args(argv)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_yaml(path: Path) -> dict:
|
|
22
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
23
|
+
if not isinstance(data, dict):
|
|
24
|
+
raise ValueError(f"{path} must contain a YAML object")
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def iter_yaml_files(root: Path) -> list[Path]:
|
|
29
|
+
return sorted(root.glob("**/*.yaml"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_index(domains_root: Path, relations_root: Path) -> dict:
|
|
33
|
+
domains: dict[str, dict] = {}
|
|
34
|
+
relations: dict[str, dict] = {}
|
|
35
|
+
citation_to_domains: dict[str, set[str]] = defaultdict(set)
|
|
36
|
+
citation_to_relations: dict[str, set[str]] = defaultdict(set)
|
|
37
|
+
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
38
|
+
incoming: dict[str, list[str]] = defaultdict(list)
|
|
39
|
+
|
|
40
|
+
for path in iter_yaml_files(domains_root):
|
|
41
|
+
data = load_yaml(path)
|
|
42
|
+
domain_id = data.get("id")
|
|
43
|
+
if not isinstance(domain_id, str) or not domain_id:
|
|
44
|
+
raise ValueError(f"{path} missing required 'id'")
|
|
45
|
+
|
|
46
|
+
boundary = data.get("boundary") or {}
|
|
47
|
+
system_type = data.get("system_type") or {}
|
|
48
|
+
context = data.get("context") or {}
|
|
49
|
+
domain_tags = sorted(set((system_type.get("tags") or []) + (context.get("tags") or [])))
|
|
50
|
+
citation_ids = sorted(c.get("id") for c in (data.get("citations") or []) if isinstance(c, dict) and c.get("id"))
|
|
51
|
+
|
|
52
|
+
must_fail_rows = []
|
|
53
|
+
for test in data.get("must_fail_tests") or []:
|
|
54
|
+
if not isinstance(test, dict):
|
|
55
|
+
continue
|
|
56
|
+
test_id = test.get("id", "<missing-test-id>")
|
|
57
|
+
severity = test.get("severity", "unknown")
|
|
58
|
+
for citation_id in test.get("citations") or []:
|
|
59
|
+
citation_to_domains[citation_id].add(domain_id)
|
|
60
|
+
must_fail_rows.append(
|
|
61
|
+
{
|
|
62
|
+
"citation_id": citation_id,
|
|
63
|
+
"test_id": test_id,
|
|
64
|
+
"severity": severity,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
domains[domain_id] = {
|
|
69
|
+
"path": str(path.as_posix()),
|
|
70
|
+
"title": data.get("title", ""),
|
|
71
|
+
"system_primary": system_type.get("primary", ""),
|
|
72
|
+
"tags": domain_tags,
|
|
73
|
+
"closure_type": boundary.get("closure_type", "unknown"),
|
|
74
|
+
"exchange_channels": sorted(boundary.get("exchange_channels") or []),
|
|
75
|
+
"citation_ids": citation_ids,
|
|
76
|
+
"must_fail_rows": sorted(
|
|
77
|
+
must_fail_rows,
|
|
78
|
+
key=lambda row: (row["citation_id"], row["test_id"], row["severity"]),
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for path in iter_yaml_files(relations_root):
|
|
83
|
+
data = load_yaml(path)
|
|
84
|
+
relation_id = data.get("id")
|
|
85
|
+
if not isinstance(relation_id, str) or not relation_id:
|
|
86
|
+
raise ValueError(f"{path} missing required 'id'")
|
|
87
|
+
|
|
88
|
+
source = data.get("source_domain_id", "")
|
|
89
|
+
target = data.get("target_domain_id", "")
|
|
90
|
+
citation_ids = sorted(c.get("id") for c in (data.get("citations") or []) if isinstance(c, dict) and c.get("id"))
|
|
91
|
+
|
|
92
|
+
must_fail_rows = []
|
|
93
|
+
for test in data.get("must_fail_tests") or []:
|
|
94
|
+
if not isinstance(test, dict):
|
|
95
|
+
continue
|
|
96
|
+
test_id = test.get("id", "<missing-test-id>")
|
|
97
|
+
severity = test.get("severity", "unknown")
|
|
98
|
+
for citation_id in test.get("citations") or []:
|
|
99
|
+
citation_to_relations[citation_id].add(relation_id)
|
|
100
|
+
must_fail_rows.append(
|
|
101
|
+
{
|
|
102
|
+
"citation_id": citation_id,
|
|
103
|
+
"test_id": test_id,
|
|
104
|
+
"severity": severity,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
relations[relation_id] = {
|
|
109
|
+
"path": str(path.as_posix()),
|
|
110
|
+
"relation_type": data.get("relation_type", "unknown"),
|
|
111
|
+
"source": source,
|
|
112
|
+
"target": target,
|
|
113
|
+
"citation_ids": citation_ids,
|
|
114
|
+
"must_fail_rows": sorted(
|
|
115
|
+
must_fail_rows,
|
|
116
|
+
key=lambda row: (row["citation_id"], row["test_id"], row["severity"]),
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
outgoing[source].append(relation_id)
|
|
120
|
+
incoming[target].append(relation_id)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"domains": {key: domains[key] for key in sorted(domains)},
|
|
124
|
+
"relations": {key: relations[key] for key in sorted(relations)},
|
|
125
|
+
"reverse": {
|
|
126
|
+
"citation_to_domains": {
|
|
127
|
+
citation_id: sorted(domain_ids)
|
|
128
|
+
for citation_id, domain_ids in sorted(citation_to_domains.items())
|
|
129
|
+
},
|
|
130
|
+
"citation_to_relations": {
|
|
131
|
+
citation_id: sorted(relation_ids)
|
|
132
|
+
for citation_id, relation_ids in sorted(citation_to_relations.items())
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
"graph": {
|
|
136
|
+
"outgoing": {
|
|
137
|
+
domain_id: sorted(relation_ids)
|
|
138
|
+
for domain_id, relation_ids in sorted(outgoing.items())
|
|
139
|
+
},
|
|
140
|
+
"incoming": {
|
|
141
|
+
domain_id: sorted(relation_ids)
|
|
142
|
+
for domain_id, relation_ids in sorted(incoming.items())
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
"meta": {
|
|
146
|
+
"generated_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
147
|
+
"domain_count": len(domains),
|
|
148
|
+
"relation_count": len(relations),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
args = parse_args(argv)
|
|
155
|
+
try:
|
|
156
|
+
index = build_index(Path(args.domains_root), Path(args.relations_root))
|
|
157
|
+
except (yaml.YAMLError, OSError, ValueError) as exc:
|
|
158
|
+
print(f"Error building index: {exc}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
out_path = Path(args.out)
|
|
162
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
out_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
164
|
+
print(f"Wrote index: {out_path}")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
10
|
+
parser = argparse.ArgumentParser(description="Extract a domain YAML draft from a template")
|
|
11
|
+
parser.add_argument("--template", required=True)
|
|
12
|
+
parser.add_argument("--out", required=True)
|
|
13
|
+
parser.add_argument("--set", dest="sets", action="append", default=[])
|
|
14
|
+
parser.add_argument("--force", action="store_true")
|
|
15
|
+
return parser.parse_args(argv)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_set(value: str) -> tuple[list[str], str]:
|
|
19
|
+
if "=" not in value:
|
|
20
|
+
raise ValueError(f"Invalid --set value '{value}', expected path=value")
|
|
21
|
+
dotted, raw = value.split("=", 1)
|
|
22
|
+
path_parts = [part for part in dotted.split(".") if part]
|
|
23
|
+
if not path_parts:
|
|
24
|
+
raise ValueError(f"Invalid --set path in '{value}'")
|
|
25
|
+
return path_parts, raw
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def coerce_value(raw: str):
|
|
29
|
+
lowered = raw.lower()
|
|
30
|
+
if lowered == "true":
|
|
31
|
+
return True
|
|
32
|
+
if lowered == "false":
|
|
33
|
+
return False
|
|
34
|
+
if lowered == "null":
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
return int(raw)
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
try:
|
|
41
|
+
return float(raw)
|
|
42
|
+
except ValueError:
|
|
43
|
+
pass
|
|
44
|
+
return raw
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def apply_path(target, path_parts: list[str], value) -> None:
|
|
48
|
+
cursor = target
|
|
49
|
+
for index, part in enumerate(path_parts[:-1]):
|
|
50
|
+
next_part = path_parts[index + 1]
|
|
51
|
+
is_next_index = next_part.isdigit()
|
|
52
|
+
|
|
53
|
+
if isinstance(cursor, list):
|
|
54
|
+
if not part.isdigit():
|
|
55
|
+
raise ValueError(f"Expected list index at '{part}' in path {'.'.join(path_parts)}")
|
|
56
|
+
item_index = int(part)
|
|
57
|
+
if item_index < 0:
|
|
58
|
+
raise ValueError("List index cannot be negative")
|
|
59
|
+
while len(cursor) <= item_index:
|
|
60
|
+
cursor.append({} if not is_next_index else [])
|
|
61
|
+
if not isinstance(cursor[item_index], (dict, list)):
|
|
62
|
+
cursor[item_index] = {} if not is_next_index else []
|
|
63
|
+
cursor = cursor[item_index]
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if not isinstance(cursor, dict):
|
|
67
|
+
raise ValueError(f"Cannot descend into non-container for path {'.'.join(path_parts)}")
|
|
68
|
+
|
|
69
|
+
if part not in cursor or cursor[part] is None:
|
|
70
|
+
cursor[part] = [] if is_next_index else {}
|
|
71
|
+
elif not isinstance(cursor[part], (dict, list)):
|
|
72
|
+
cursor[part] = [] if is_next_index else {}
|
|
73
|
+
|
|
74
|
+
cursor = cursor[part]
|
|
75
|
+
|
|
76
|
+
final = path_parts[-1]
|
|
77
|
+
if isinstance(cursor, list):
|
|
78
|
+
if not final.isdigit():
|
|
79
|
+
raise ValueError(f"Expected list index at final segment '{final}'")
|
|
80
|
+
final_index = int(final)
|
|
81
|
+
if final_index < 0:
|
|
82
|
+
raise ValueError("List index cannot be negative")
|
|
83
|
+
while len(cursor) <= final_index:
|
|
84
|
+
cursor.append(None)
|
|
85
|
+
cursor[final_index] = value
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if not isinstance(cursor, dict):
|
|
89
|
+
raise ValueError(f"Cannot assign into non-dict at path {'.'.join(path_parts)}")
|
|
90
|
+
cursor[final] = value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_template(path: Path):
|
|
94
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise ValueError("Template must be a YAML object")
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None) -> int:
|
|
101
|
+
args = parse_args(argv)
|
|
102
|
+
template_path = Path(args.template)
|
|
103
|
+
out_path = Path(args.out)
|
|
104
|
+
|
|
105
|
+
if out_path.exists() and not args.force:
|
|
106
|
+
print(f"Refusing to overwrite existing file: {out_path}")
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
payload = load_template(template_path)
|
|
111
|
+
for assignment in args.sets:
|
|
112
|
+
path_parts, raw_value = parse_set(assignment)
|
|
113
|
+
apply_path(payload, path_parts, coerce_value(raw_value))
|
|
114
|
+
except (OSError, yaml.YAMLError, ValueError) as exc:
|
|
115
|
+
print(f"Error: {exc}")
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
out_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
|
120
|
+
print(f"Wrote draft: {out_path}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|