scriptoria 0.4.0__tar.gz → 0.5.1__tar.gz
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.
- {scriptoria-0.4.0 → scriptoria-0.5.1}/.gitignore +4 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/PKG-INFO +1 -1
- {scriptoria-0.4.0 → scriptoria-0.5.1}/pyproject.toml +1 -1
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/__init__.py +1 -1
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/anchors.py +55 -28
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/cli.py +43 -3
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/facts.py +126 -24
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/frontmatter.py +27 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/graph.py +25 -18
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/hashing.py +2 -5
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/query.py +21 -3
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/similar.py +20 -19
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_fact_cmd.py +116 -0
- scriptoria-0.5.1/tests/test_frontmatter.py +97 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_graph_status.py +78 -1
- scriptoria-0.5.1/tests/test_query.py +109 -0
- scriptoria-0.5.1/tests/test_span_cmd.py +86 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/uv.lock +1 -1
- scriptoria-0.4.0/tests/test_query.py +0 -58
- {scriptoria-0.4.0 → scriptoria-0.5.1}/README.md +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/pyrightconfig.json +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/blocks.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/errors.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/ingest.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/lock.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/manifest.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/retrieval.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/conftest.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_anchors.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_blocks.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_embeddings.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_hashing.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_ingest.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_lock.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_manifest.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_retrieval.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_stamp.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_verify.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_version.py +0 -0
- {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptoria
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base
|
|
5
5
|
Project-URL: Homepage, https://github.com/coredipper/scriptorium
|
|
6
6
|
Project-URL: Changelog, https://github.com/coredipper/scriptorium/blob/main/CHANGELOG.md
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the
|
|
3
3
|
# CLI command and the import package both remain `scrip`.
|
|
4
4
|
name = "scriptoria"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.5.1"
|
|
6
6
|
description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.10"
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.5.1"
|
|
17
17
|
|
|
18
18
|
# --- canonical vault layout ------------------------------------------------
|
|
19
19
|
# ``root`` is the repo/instance root: the directory containing ``vault/``.
|
|
@@ -90,6 +90,33 @@ def resolve(source_text: str, anchor: str) -> str:
|
|
|
90
90
|
return "OK" if matches == 1 else "AMBIGUOUS"
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def span(source_text: str, anchor: str) -> tuple[str, str | None]:
|
|
94
|
+
"""Return ``(status, cited_text)`` for ``anchor`` in ``source_text``.
|
|
95
|
+
|
|
96
|
+
Same verdicts as :func:`resolve`, but also returns the matched span (the
|
|
97
|
+
normalized cited text) so a caller can *read* what an anchor cites. For
|
|
98
|
+
``AMBIGUOUS`` the window nearest the anchor's ``loc`` hint is returned; for
|
|
99
|
+
``BROKEN`` the text is ``None``.
|
|
100
|
+
"""
|
|
101
|
+
a = parse_anchor(anchor)
|
|
102
|
+
ns = normalize(source_text)
|
|
103
|
+
n, target, loc = a["len"], a["qh"], a["loc"]
|
|
104
|
+
length = len(ns)
|
|
105
|
+
if n <= 0 or n > length:
|
|
106
|
+
return "BROKEN", None
|
|
107
|
+
hits = [
|
|
108
|
+
start
|
|
109
|
+
for start in range(length - n + 1)
|
|
110
|
+
if hashlib.sha256(ns[start : start + n].encode("utf-8")).hexdigest() == target
|
|
111
|
+
]
|
|
112
|
+
if not hits:
|
|
113
|
+
return "BROKEN", None
|
|
114
|
+
if len(hits) == 1:
|
|
115
|
+
return "OK", ns[hits[0] : hits[0] + n]
|
|
116
|
+
nearest = min(hits, key=lambda s: abs(s - loc * length))
|
|
117
|
+
return "AMBIGUOUS", ns[nearest : nearest + n]
|
|
118
|
+
|
|
119
|
+
|
|
93
120
|
# --------------------------------------------------------------------------- #
|
|
94
121
|
# Vault-wide verification
|
|
95
122
|
# --------------------------------------------------------------------------- #
|
|
@@ -112,15 +139,16 @@ def source_text(root: Path, source_id: str) -> str:
|
|
|
112
139
|
|
|
113
140
|
|
|
114
141
|
def _iter_footnote_anchors(path: Path) -> Iterator[dict]:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
with path.open(encoding="utf-8") as f:
|
|
143
|
+
for line in f:
|
|
144
|
+
m = _FOOTNOTE.match(line)
|
|
145
|
+
if not m:
|
|
146
|
+
continue
|
|
147
|
+
target = m.group(2)
|
|
148
|
+
if "#" not in target:
|
|
149
|
+
continue
|
|
150
|
+
source_id, anchor = target.split("#", 1)
|
|
151
|
+
yield {"fn": m.group(1), "source_id": source_id, "anchor": anchor}
|
|
124
152
|
|
|
125
153
|
|
|
126
154
|
def verify_vault(root: Path) -> dict:
|
|
@@ -152,25 +180,24 @@ def verify_vault(root: Path) -> dict:
|
|
|
152
180
|
claims_path = facts_dir(root) / "claims.ndjson"
|
|
153
181
|
if claims_path.exists():
|
|
154
182
|
seen: set[str] = set()
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
record(f"claim:{cid}", rec, resolve(text, rec["anchor"]))
|
|
183
|
+
with claims_path.open(encoding="utf-8") as f:
|
|
184
|
+
for lineno, raw_line in enumerate(f, start=1):
|
|
185
|
+
line = raw_line.strip()
|
|
186
|
+
if not line:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
rec = json.loads(line)
|
|
190
|
+
except json.JSONDecodeError as e:
|
|
191
|
+
raise DataError(f"claims.ndjson:{lineno}: invalid JSON: {e}") from e
|
|
192
|
+
for key in ("claim_id", "source_id", "anchor"):
|
|
193
|
+
if key not in rec:
|
|
194
|
+
raise DataError(f"claims.ndjson:{lineno}: missing '{key}'")
|
|
195
|
+
cid = rec["claim_id"]
|
|
196
|
+
if cid in seen:
|
|
197
|
+
raise DataError(f"duplicate claim_id: {cid}")
|
|
198
|
+
seen.add(cid)
|
|
199
|
+
text = _source_text(root, rec["source_id"], src_cache)
|
|
200
|
+
record(f"claim:{cid}", rec, resolve(text, rec["anchor"]))
|
|
174
201
|
|
|
175
202
|
wd = wiki_dir(root)
|
|
176
203
|
if wd.is_dir():
|
|
@@ -352,6 +352,31 @@ def _parse_source_ids(raw: str) -> list[str]:
|
|
|
352
352
|
return ids
|
|
353
353
|
|
|
354
354
|
|
|
355
|
+
def cmd_span(args: argparse.Namespace) -> int:
|
|
356
|
+
from . import anchors
|
|
357
|
+
|
|
358
|
+
root = resolve_root(args.root)
|
|
359
|
+
if args.claim:
|
|
360
|
+
from . import facts
|
|
361
|
+
|
|
362
|
+
source_id, anchor = facts.claim_source_anchor(root, args.claim)
|
|
363
|
+
else:
|
|
364
|
+
if "#" not in args.target:
|
|
365
|
+
raise errors.UsageError("target must be raw/<slug>#<anchor>")
|
|
366
|
+
source_id, anchor = args.target.split("#", 1)
|
|
367
|
+
source_id = source_id if source_id.startswith("raw/") else f"raw/{source_id}"
|
|
368
|
+
_safe_slug(source_id[len("raw/") :], "source")
|
|
369
|
+
text = anchors.source_text(root, source_id)
|
|
370
|
+
status, cited = anchors.span(text, anchor)
|
|
371
|
+
if args.json:
|
|
372
|
+
_emit({"target": f"{source_id}#{anchor}", "status": status, "text": cited})
|
|
373
|
+
else:
|
|
374
|
+
print(f"[{status}] {source_id}")
|
|
375
|
+
if cited is not None:
|
|
376
|
+
print(cited)
|
|
377
|
+
return 0 if status == "OK" else 1
|
|
378
|
+
|
|
379
|
+
|
|
355
380
|
def cmd_similar(args: argparse.Namespace) -> int:
|
|
356
381
|
from . import similar
|
|
357
382
|
|
|
@@ -388,7 +413,12 @@ def cmd_fact_add(args: argparse.Namespace) -> int:
|
|
|
388
413
|
_emit(result)
|
|
389
414
|
else:
|
|
390
415
|
for r in result["appended"]:
|
|
391
|
-
ident =
|
|
416
|
+
ident = (
|
|
417
|
+
r.get("claim_id")
|
|
418
|
+
or r.get("entity_id")
|
|
419
|
+
or r.get("reconciliation_id")
|
|
420
|
+
or f"{r.get('src')} -> {r.get('dst')}"
|
|
421
|
+
)
|
|
392
422
|
print(f" appended {ident}")
|
|
393
423
|
for s in result["skipped"]:
|
|
394
424
|
print(f" = record {s['index']} skipped (duplicate)")
|
|
@@ -517,7 +547,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
517
547
|
pq.add_argument(
|
|
518
548
|
"name",
|
|
519
549
|
nargs="?",
|
|
520
|
-
choices=["claims", "entities", "edges", "contradictions"],
|
|
550
|
+
choices=["claims", "entities", "edges", "contradictions", "reconciliations"],
|
|
521
551
|
help="a named query (omit when using --sql)",
|
|
522
552
|
)
|
|
523
553
|
pq.add_argument("--sql", help="raw DuckDB SQL (views: claims, entities, edges)")
|
|
@@ -585,6 +615,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
585
615
|
pn.add_argument("--title", help="human title (default: the slug)")
|
|
586
616
|
pn.set_defaults(func=cmd_new)
|
|
587
617
|
|
|
618
|
+
psp = sub.add_parser(
|
|
619
|
+
"span",
|
|
620
|
+
parents=[common],
|
|
621
|
+
help="resolve an anchor and print the cited text (read both sides of a contradiction)",
|
|
622
|
+
)
|
|
623
|
+
span_src = psp.add_mutually_exclusive_group(required=True)
|
|
624
|
+
span_src.add_argument("target", nargs="?", metavar="raw/<slug>#<anchor>", help="anchor target")
|
|
625
|
+
span_src.add_argument("--claim", metavar="ID", help="resolve this claim's anchor instead")
|
|
626
|
+
psp.set_defaults(func=cmd_span)
|
|
627
|
+
|
|
588
628
|
psim = sub.add_parser(
|
|
589
629
|
"similar",
|
|
590
630
|
parents=[common],
|
|
@@ -629,7 +669,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
629
669
|
)
|
|
630
670
|
pfa.add_argument(
|
|
631
671
|
"--table",
|
|
632
|
-
choices=["claims", "entities", "edges"],
|
|
672
|
+
choices=["claims", "entities", "edges", "reconciliations"],
|
|
633
673
|
default="claims",
|
|
634
674
|
help="facts table to append to (default: claims)",
|
|
635
675
|
)
|
|
@@ -36,10 +36,12 @@ _FILES = {
|
|
|
36
36
|
"claims": "claims.ndjson",
|
|
37
37
|
"entities": "entities.ndjson",
|
|
38
38
|
"edges": "graph.ndjson",
|
|
39
|
+
"reconciliations": "reconciliations.ndjson",
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
# Fields scrip mints itself; proposing them is a schema error, not a finding.
|
|
42
43
|
_SCRIP_OWNED = ("claim_id", "anchor", "extracted_at")
|
|
44
|
+
_RECON_OWNED = ("reconciliation_id", "at")
|
|
43
45
|
|
|
44
46
|
_CLAIM_REQUIRED = ("quote", "source_id", "subject", "predicate", "object", "polarity", "confidence")
|
|
45
47
|
_CLAIM_ALLOWED = frozenset((*_CLAIM_REQUIRED, "claim_text", "tags"))
|
|
@@ -47,12 +49,16 @@ _ENTITY_REQUIRED = ("entity_id", "name", "kind")
|
|
|
47
49
|
_ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags"))
|
|
48
50
|
_EDGE_REQUIRED = ("src", "dst", "kind")
|
|
49
51
|
_EDGE_ALLOWED = frozenset(_EDGE_REQUIRED)
|
|
52
|
+
_DECISIONS = ("supersede", "qualify", "keep-both")
|
|
53
|
+
_RECON_REQUIRED = ("decision", "claim_a", "claim_b")
|
|
54
|
+
_RECON_ALLOWED = frozenset((*_RECON_REQUIRED, "winner", "rationale"))
|
|
50
55
|
|
|
51
56
|
# Same conservative shape ``cli._safe_slug`` enforces — no path separators,
|
|
52
57
|
# '..', or leading dot — applied to source ids arriving as record *data*.
|
|
53
58
|
_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
54
59
|
|
|
55
60
|
_CLAIM_ID_RE = re.compile(r"clm_(\d+)")
|
|
61
|
+
_RECON_ID_RE = re.compile(r"rec_(\d+)")
|
|
56
62
|
|
|
57
63
|
|
|
58
64
|
def _now() -> str:
|
|
@@ -96,12 +102,18 @@ def _check_tags(rec: dict, index: int) -> None:
|
|
|
96
102
|
raise DataError(f"record {index}: 'tags' must be a list of strings")
|
|
97
103
|
|
|
98
104
|
|
|
99
|
-
def _check_shape(
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
def _check_shape(
|
|
106
|
+
rec: dict,
|
|
107
|
+
index: int,
|
|
108
|
+
required: tuple[str, ...],
|
|
109
|
+
allowed: frozenset[str],
|
|
110
|
+
owned: tuple[str, ...] = _SCRIP_OWNED,
|
|
111
|
+
) -> None:
|
|
112
|
+
present = [k for k in owned if k in rec]
|
|
113
|
+
if present:
|
|
102
114
|
raise DataError(
|
|
103
|
-
f"record {index}: scrip mints {', '.join(
|
|
104
|
-
f"
|
|
115
|
+
f"record {index}: scrip mints {', '.join(present)} itself — do not "
|
|
116
|
+
f"propose precomputed ids/anchors/timestamps"
|
|
105
117
|
)
|
|
106
118
|
unknown = sorted(rec.keys() - allowed)
|
|
107
119
|
if unknown:
|
|
@@ -136,10 +148,27 @@ def _validate(table: str, rec: dict, index: int) -> None:
|
|
|
136
148
|
if not (eid.startswith("entity/") and _SLUG_RE.fullmatch(eid[len("entity/") :])):
|
|
137
149
|
raise DataError(f"record {index}: entity_id must look like entity/<slug>")
|
|
138
150
|
_check_tags(rec, index)
|
|
139
|
-
|
|
151
|
+
elif table == "edges":
|
|
140
152
|
_check_shape(rec, index, _EDGE_REQUIRED, _EDGE_ALLOWED)
|
|
141
153
|
for key in _EDGE_REQUIRED:
|
|
142
154
|
_check_str(rec, key, index)
|
|
155
|
+
else: # reconciliations
|
|
156
|
+
_check_shape(rec, index, _RECON_REQUIRED, _RECON_ALLOWED, owned=_RECON_OWNED)
|
|
157
|
+
for key in ("decision", "claim_a", "claim_b"):
|
|
158
|
+
_check_str(rec, key, index)
|
|
159
|
+
if rec["decision"] not in _DECISIONS:
|
|
160
|
+
raise DataError(f"record {index}: decision must be one of {', '.join(_DECISIONS)}")
|
|
161
|
+
if "rationale" in rec:
|
|
162
|
+
_check_str(rec, "rationale", index, allow_blank=True)
|
|
163
|
+
# winner is required for (and only for) supersede, and must be one of the pair
|
|
164
|
+
if rec["decision"] == "supersede":
|
|
165
|
+
winner = rec.get("winner")
|
|
166
|
+
if winner not in (rec["claim_a"], rec["claim_b"]):
|
|
167
|
+
raise DataError(
|
|
168
|
+
f"record {index}: supersede needs 'winner' = claim_a or claim_b"
|
|
169
|
+
)
|
|
170
|
+
elif "winner" in rec:
|
|
171
|
+
raise DataError(f"record {index}: 'winner' is only valid for decision 'supersede'")
|
|
143
172
|
|
|
144
173
|
|
|
145
174
|
# --------------------------------------------------------------------------- #
|
|
@@ -199,23 +228,48 @@ def _resolve_claim(
|
|
|
199
228
|
# --------------------------------------------------------------------------- #
|
|
200
229
|
# Existing-table reads & keys
|
|
201
230
|
# --------------------------------------------------------------------------- #
|
|
202
|
-
def
|
|
203
|
-
"""
|
|
204
|
-
|
|
231
|
+
def _ends_with_newline(path: Path) -> bool:
|
|
232
|
+
"""Whether the file's final byte is a newline (an empty/absent file counts as
|
|
233
|
+
terminated, so no separator is prepended before the next append)."""
|
|
234
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
235
|
+
return True
|
|
236
|
+
with path.open("rb") as f:
|
|
237
|
+
f.seek(-1, 2)
|
|
238
|
+
return f.read(1) == b"\n"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_table(path: Path) -> tuple[list[dict], bool]:
|
|
242
|
+
"""Read an NDJSON table, returning ``(records, ends_with_newline)``. Streams the
|
|
243
|
+
file line-by-line rather than loading it whole. Malformed lines are a
|
|
244
|
+
:class:`DataError` — the vault on disk violates the contract."""
|
|
205
245
|
if not path.exists():
|
|
206
|
-
return [],
|
|
207
|
-
|
|
246
|
+
return [], True
|
|
247
|
+
ends_newline = _ends_with_newline(path)
|
|
208
248
|
records: list[dict] = []
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
249
|
+
with path.open(encoding="utf-8") as f:
|
|
250
|
+
for lineno, raw_line in enumerate(f, start=1):
|
|
251
|
+
line = raw_line.strip()
|
|
252
|
+
if not line:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
rec = json.loads(line)
|
|
256
|
+
except json.JSONDecodeError as e:
|
|
257
|
+
raise DataError(f"{path.name}:{lineno}: invalid JSON: {e}") from e
|
|
258
|
+
records.append(rec)
|
|
259
|
+
return records, ends_newline
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def claim_source_anchor(root: Path, claim_id: str) -> tuple[str, str]:
|
|
263
|
+
"""Return ``(source_id, anchor)`` for a claim, for `scrip span --claim`.
|
|
264
|
+
Raises :class:`DataError` if the claim is missing or lacks the fields."""
|
|
265
|
+
records, _ = _read_table(facts_dir(root) / "claims.ndjson")
|
|
266
|
+
for rec in records:
|
|
267
|
+
if rec.get("claim_id") == claim_id:
|
|
268
|
+
sid, anchor = rec.get("source_id"), rec.get("anchor")
|
|
269
|
+
if not isinstance(sid, str) or not isinstance(anchor, str):
|
|
270
|
+
raise DataError(f"claim {claim_id} is missing source_id/anchor")
|
|
271
|
+
return sid, anchor
|
|
272
|
+
raise DataError(f"no such claim: {claim_id}")
|
|
219
273
|
|
|
220
274
|
|
|
221
275
|
def _claim_key(source_id: str, qh: str, rec: dict) -> tuple:
|
|
@@ -251,6 +305,17 @@ def _next_claim_id(existing: list[dict]) -> tuple[int, int]:
|
|
|
251
305
|
return highest + 1, max(4, len(str(highest)))
|
|
252
306
|
|
|
253
307
|
|
|
308
|
+
def _next_recon_id(existing: list[dict]) -> tuple[int, int]:
|
|
309
|
+
"""Return ``(next_number, pad_width)`` continuing the ``rec_NNNN`` sequence."""
|
|
310
|
+
numbers = [
|
|
311
|
+
int(m.group(1))
|
|
312
|
+
for rec in existing
|
|
313
|
+
if (m := _RECON_ID_RE.fullmatch(str(rec.get("reconciliation_id", ""))))
|
|
314
|
+
]
|
|
315
|
+
highest = max(numbers, default=0)
|
|
316
|
+
return highest + 1, max(4, len(str(highest)))
|
|
317
|
+
|
|
318
|
+
|
|
254
319
|
# --------------------------------------------------------------------------- #
|
|
255
320
|
# facts/_meta.yaml: merge derived-from, never stamp
|
|
256
321
|
# --------------------------------------------------------------------------- #
|
|
@@ -331,7 +396,7 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
331
396
|
if failures:
|
|
332
397
|
return {"table": table, "appended": [], "skipped": [], "failures": failures}
|
|
333
398
|
|
|
334
|
-
existing,
|
|
399
|
+
existing, ends_with_newline = _read_table(path)
|
|
335
400
|
meta = _load_meta(root) # parse before appending: fail whole, not half
|
|
336
401
|
appended: list[dict] = []
|
|
337
402
|
skipped: list[dict] = []
|
|
@@ -394,7 +459,7 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
394
459
|
"detail": "an entity with this id already exists with different fields",
|
|
395
460
|
}
|
|
396
461
|
)
|
|
397
|
-
|
|
462
|
+
elif table == "edges":
|
|
398
463
|
seen_edges = {
|
|
399
464
|
(rec.get("src"), rec.get("dst"), rec.get("kind")) for rec in existing
|
|
400
465
|
}
|
|
@@ -405,6 +470,43 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
405
470
|
continue
|
|
406
471
|
seen_edges.add(key)
|
|
407
472
|
appended.append({"src": rec["src"], "dst": rec["dst"], "kind": rec["kind"]})
|
|
473
|
+
else: # reconciliations
|
|
474
|
+
claim_ids = {c.get("claim_id") for c in _read_table(facts_dir(root) / "claims.ndjson")[0]}
|
|
475
|
+
for i, rec in enumerate(proposals):
|
|
476
|
+
refs = [rec["claim_a"], rec["claim_b"]]
|
|
477
|
+
if rec["decision"] == "supersede":
|
|
478
|
+
refs.append(rec["winner"])
|
|
479
|
+
missing = next((r for r in refs if r not in claim_ids), None)
|
|
480
|
+
if missing is not None:
|
|
481
|
+
failures.append({
|
|
482
|
+
"index": i, "status": "MISSING_CLAIM", "claim": missing,
|
|
483
|
+
"detail": f"{missing!r} is not a claim in claims.ndjson",
|
|
484
|
+
})
|
|
485
|
+
if failures:
|
|
486
|
+
return {"table": table, "appended": [], "skipped": [], "failures": failures}
|
|
487
|
+
seen_pairs = {frozenset((r.get("claim_a"), r.get("claim_b"))) for r in existing}
|
|
488
|
+
number, width = _next_recon_id(existing)
|
|
489
|
+
now = _now()
|
|
490
|
+
for i, rec in enumerate(proposals):
|
|
491
|
+
pair = frozenset((rec["claim_a"], rec["claim_b"]))
|
|
492
|
+
if pair in seen_pairs:
|
|
493
|
+
skipped.append({"index": i, "reason": "duplicate", "existing_id": None})
|
|
494
|
+
continue
|
|
495
|
+
seen_pairs.add(pair)
|
|
496
|
+
rid = f"rec_{number:0{width}d}"
|
|
497
|
+
number += 1
|
|
498
|
+
full = {
|
|
499
|
+
"reconciliation_id": rid,
|
|
500
|
+
"decision": rec["decision"],
|
|
501
|
+
"claim_a": rec["claim_a"],
|
|
502
|
+
"claim_b": rec["claim_b"],
|
|
503
|
+
}
|
|
504
|
+
if rec["decision"] == "supersede":
|
|
505
|
+
full["winner"] = rec["winner"]
|
|
506
|
+
if rec.get("rationale"):
|
|
507
|
+
full["rationale"] = rec["rationale"]
|
|
508
|
+
full["at"] = now
|
|
509
|
+
appended.append(full)
|
|
408
510
|
|
|
409
511
|
if failures:
|
|
410
512
|
return {"table": table, "appended": [], "skipped": skipped, "failures": failures}
|
|
@@ -413,7 +515,7 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
413
515
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
414
516
|
payload = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in appended)
|
|
415
517
|
with open(path, "a", encoding="utf-8") as f:
|
|
416
|
-
if
|
|
518
|
+
if not ends_with_newline:
|
|
417
519
|
f.write("\n")
|
|
418
520
|
f.write(payload)
|
|
419
521
|
new_sources: list[str] = []
|
|
@@ -60,3 +60,30 @@ def require(meta: dict, keys: Iterable[str], where: str = "frontmatter") -> None
|
|
|
60
60
|
missing = [k for k in keys if k not in meta]
|
|
61
61
|
if missing:
|
|
62
62
|
raise DataError(f"{where}: missing required key(s): {', '.join(missing)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def as_str(meta: dict, key: str, where: str = "frontmatter") -> str | None:
|
|
66
|
+
"""Return ``meta[key]`` as a string, or ``None`` if absent/null. Raise
|
|
67
|
+
:class:`DataError` (naming ``where``) if present but not a string."""
|
|
68
|
+
value = meta.get(key)
|
|
69
|
+
if value is None:
|
|
70
|
+
return None
|
|
71
|
+
if not isinstance(value, str):
|
|
72
|
+
raise DataError(
|
|
73
|
+
f"{where}: '{key}' must be a string, got {type(value).__name__}"
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_str_list(meta: dict, key: str, where: str = "frontmatter") -> list[str]:
|
|
79
|
+
"""Return ``meta[key]`` as a list of strings, or ``[]`` if absent/null. Raise
|
|
80
|
+
:class:`DataError` (naming ``where``) if present but not a list of strings.
|
|
81
|
+
|
|
82
|
+
This is what stops a hand-edited bare string (``derived-from: raw/x``) from
|
|
83
|
+
silently char-splitting into per-character dependency ids downstream."""
|
|
84
|
+
value = meta.get(key)
|
|
85
|
+
if value is None:
|
|
86
|
+
return []
|
|
87
|
+
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
88
|
+
raise DataError(f"{where}: '{key}' must be a list of strings")
|
|
89
|
+
return value
|
|
@@ -80,28 +80,35 @@ def scan_derived(root: Path) -> dict:
|
|
|
80
80
|
meta, _ = frontmatter.load(path)
|
|
81
81
|
if not meta or "derived-from" not in meta:
|
|
82
82
|
continue # index.md, log.md, hand notes: not tracked artifacts
|
|
83
|
-
|
|
83
|
+
where = str(path.relative_to(root))
|
|
84
|
+
did = frontmatter.as_str(meta, "id", where) or where
|
|
84
85
|
out[did] = {
|
|
85
|
-
"path":
|
|
86
|
-
"type":
|
|
87
|
-
"derived_from":
|
|
88
|
-
"input_hash":
|
|
89
|
-
"last_compiled":
|
|
86
|
+
"path": where,
|
|
87
|
+
"type": frontmatter.as_str(meta, "type", where) or "wiki",
|
|
88
|
+
"derived_from": frontmatter.as_str_list(meta, "derived-from", where),
|
|
89
|
+
"input_hash": frontmatter.as_str(meta, "input-hash", where),
|
|
90
|
+
"last_compiled": frontmatter.as_str(meta, "last-compiled", where),
|
|
90
91
|
}
|
|
91
92
|
fmeta = facts_dir(root) / "_meta.yaml"
|
|
92
93
|
if fmeta.exists():
|
|
93
94
|
try:
|
|
94
|
-
data = yaml.safe_load(fmeta.read_text(encoding="utf-8"))
|
|
95
|
+
data = yaml.safe_load(fmeta.read_text(encoding="utf-8"))
|
|
95
96
|
except yaml.YAMLError as e:
|
|
96
97
|
raise DataError(f"invalid facts/_meta.yaml: {e}") from e
|
|
97
|
-
if
|
|
98
|
-
|
|
98
|
+
if data is None:
|
|
99
|
+
data = {} # empty / null file: legitimately "no facts set"
|
|
100
|
+
elif not isinstance(data, dict):
|
|
101
|
+
# don't let `or {}` mask falsey non-mappings ([], false, 0, '')
|
|
102
|
+
raise DataError("facts/_meta.yaml must be a YAML mapping")
|
|
103
|
+
if "derived-from" in data:
|
|
104
|
+
where = str(fmeta.relative_to(root))
|
|
105
|
+
did = frontmatter.as_str(data, "id", where) or "facts/core"
|
|
99
106
|
out[did] = {
|
|
100
|
-
"path":
|
|
101
|
-
"type":
|
|
102
|
-
"derived_from":
|
|
103
|
-
"input_hash":
|
|
104
|
-
"last_compiled":
|
|
107
|
+
"path": where,
|
|
108
|
+
"type": frontmatter.as_str(data, "type", where) or "facts.set",
|
|
109
|
+
"derived_from": frontmatter.as_str_list(data, "derived-from", where),
|
|
110
|
+
"input_hash": frontmatter.as_str(data, "input-hash", where),
|
|
111
|
+
"last_compiled": frontmatter.as_str(data, "last-compiled", where),
|
|
105
112
|
}
|
|
106
113
|
return out
|
|
107
114
|
|
|
@@ -114,10 +121,10 @@ def _dep_hash(dep_id: str, raw: dict) -> str | None:
|
|
|
114
121
|
r = raw.get(base)
|
|
115
122
|
if not r:
|
|
116
123
|
return None
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
124
|
+
# ⚡ Bolt: Replace O(n) loop with O(1) hash map lookup for blocks
|
|
125
|
+
if "_blocks_map" not in r:
|
|
126
|
+
r["_blocks_map"] = {b["block_id"]: b["hash"] for b in r["blocks"]}
|
|
127
|
+
return r["_blocks_map"].get(block_id)
|
|
121
128
|
r = raw.get(dep_id)
|
|
122
129
|
return r["content_hash"] if r else None
|
|
123
130
|
|
|
@@ -7,13 +7,10 @@ self-describing in the manifest and frontmatter.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
|
-
import re
|
|
11
10
|
import unicodedata
|
|
12
11
|
from collections.abc import Mapping
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
15
|
-
_WS = re.compile(r"\s+")
|
|
16
|
-
|
|
17
14
|
|
|
18
15
|
def normalize(text: str) -> str:
|
|
19
16
|
"""Canonical text normalization shared by provenance anchors and block ids.
|
|
@@ -23,8 +20,8 @@ def normalize(text: str) -> str:
|
|
|
23
20
|
``blocks`` import) means anchor identity and block identity can never drift.
|
|
24
21
|
"""
|
|
25
22
|
t = unicodedata.normalize("NFC", text)
|
|
26
|
-
|
|
27
|
-
return t.lower()
|
|
23
|
+
# Using split() and join() is significantly faster than regex for whitespace normalization
|
|
24
|
+
return " ".join(t.split()).lower()
|
|
28
25
|
|
|
29
26
|
|
|
30
27
|
def _digest(data: bytes) -> str:
|
|
@@ -22,15 +22,18 @@ _VIEWS = {
|
|
|
22
22
|
"claims": "claims.ndjson",
|
|
23
23
|
"entities": "entities.ndjson",
|
|
24
24
|
"edges": "graph.ndjson",
|
|
25
|
+
"reconciliations": "reconciliations.ndjson",
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
_NAMED = {
|
|
28
29
|
"claims": "SELECT * FROM claims",
|
|
29
30
|
"entities": "SELECT * FROM entities",
|
|
30
31
|
"edges": "SELECT * FROM edges",
|
|
32
|
+
"reconciliations": "SELECT * FROM reconciliations",
|
|
31
33
|
# contradiction *candidates*: same subject+predicate, opposing polarity,
|
|
32
|
-
# from different sources
|
|
33
|
-
#
|
|
34
|
+
# from different sources, AND not yet adjudicated (no reconciliation record
|
|
35
|
+
# for the pair, either order) — so RECONCILE makes the set converge.
|
|
36
|
+
# Detection is deterministic; adjudication is the agent's job.
|
|
34
37
|
"contradictions": """
|
|
35
38
|
SELECT a.claim_id AS claim_a, b.claim_id AS claim_b,
|
|
36
39
|
a.subject, a.predicate,
|
|
@@ -41,10 +44,15 @@ _NAMED = {
|
|
|
41
44
|
WHERE a.polarity = 'asserts'
|
|
42
45
|
AND b.polarity = 'denies'
|
|
43
46
|
AND a.source_id <> b.source_id
|
|
47
|
+
AND NOT EXISTS (
|
|
48
|
+
SELECT 1 FROM reconciliations r
|
|
49
|
+
WHERE (r.claim_a = a.claim_id AND r.claim_b = b.claim_id)
|
|
50
|
+
OR (r.claim_a = b.claim_id AND r.claim_b = a.claim_id)
|
|
51
|
+
)
|
|
44
52
|
""",
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
_FILTERABLE = {"claims", "entities", "edges"}
|
|
55
|
+
_FILTERABLE = {"claims", "entities", "edges", "reconciliations"}
|
|
48
56
|
|
|
49
57
|
|
|
50
58
|
def _connect(root: Path) -> duckdb.DuckDBPyConnection:
|
|
@@ -57,6 +65,16 @@ def _connect(root: Path) -> duckdb.DuckDBPyConnection:
|
|
|
57
65
|
f"CREATE VIEW {view} AS "
|
|
58
66
|
f"SELECT * FROM read_ndjson_auto('{p.as_posix()}')"
|
|
59
67
|
)
|
|
68
|
+
elif view == "reconciliations":
|
|
69
|
+
# Always present (empty stub) so `contradictions` can anti-join it and
|
|
70
|
+
# raw SQL over its columns works even before any reconciliation exists.
|
|
71
|
+
con.execute(
|
|
72
|
+
"CREATE VIEW reconciliations AS SELECT "
|
|
73
|
+
"NULL::VARCHAR AS reconciliation_id, NULL::VARCHAR AS decision, "
|
|
74
|
+
"NULL::VARCHAR AS claim_a, NULL::VARCHAR AS claim_b, "
|
|
75
|
+
"NULL::VARCHAR AS winner, NULL::VARCHAR AS rationale, "
|
|
76
|
+
"NULL::VARCHAR AS at WHERE FALSE"
|
|
77
|
+
)
|
|
60
78
|
return con
|
|
61
79
|
|
|
62
80
|
|