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.
Files changed (48) hide show
  1. {scriptoria-0.4.0 → scriptoria-0.5.1}/.gitignore +4 -0
  2. {scriptoria-0.4.0 → scriptoria-0.5.1}/PKG-INFO +1 -1
  3. {scriptoria-0.4.0 → scriptoria-0.5.1}/pyproject.toml +1 -1
  4. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/__init__.py +1 -1
  5. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/anchors.py +55 -28
  6. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/cli.py +43 -3
  7. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/facts.py +126 -24
  8. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/frontmatter.py +27 -0
  9. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/graph.py +25 -18
  10. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/hashing.py +2 -5
  11. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/query.py +21 -3
  12. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/similar.py +20 -19
  13. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_fact_cmd.py +116 -0
  14. scriptoria-0.5.1/tests/test_frontmatter.py +97 -0
  15. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_graph_status.py +78 -1
  16. scriptoria-0.5.1/tests/test_query.py +109 -0
  17. scriptoria-0.5.1/tests/test_span_cmd.py +86 -0
  18. {scriptoria-0.4.0 → scriptoria-0.5.1}/uv.lock +1 -1
  19. scriptoria-0.4.0/tests/test_query.py +0 -58
  20. {scriptoria-0.4.0 → scriptoria-0.5.1}/README.md +0 -0
  21. {scriptoria-0.4.0 → scriptoria-0.5.1}/pyrightconfig.json +0 -0
  22. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/blocks.py +0 -0
  23. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/embeddings.py +0 -0
  24. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/errors.py +0 -0
  25. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/ingest.py +0 -0
  26. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/lock.py +0 -0
  27. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/manifest.py +0 -0
  28. {scriptoria-0.4.0 → scriptoria-0.5.1}/src/scrip/retrieval.py +0 -0
  29. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/conftest.py +0 -0
  30. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_anchor_cmd.py +0 -0
  31. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_anchors.py +0 -0
  32. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_blocks.py +0 -0
  33. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_embeddings.py +0 -0
  34. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_hashing.py +0 -0
  35. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_index_cmd.py +0 -0
  36. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_ingest.py +0 -0
  37. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_json_shapes.py +0 -0
  38. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_lock.py +0 -0
  39. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_manifest.py +0 -0
  40. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_new_cmd.py +0 -0
  41. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_retrieval.py +0 -0
  42. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_similar_cmd.py +0 -0
  43. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_stamp.py +0 -0
  44. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_status_cmd.py +0 -0
  45. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_unlock_cmd.py +0 -0
  46. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_verify.py +0 -0
  47. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_version.py +0 -0
  48. {scriptoria-0.4.0 → scriptoria-0.5.1}/tests/test_watch.py +0 -0
@@ -29,3 +29,7 @@ build/
29
29
 
30
30
  # roborev snapshots
31
31
  /.roborev/
32
+
33
+ # Obsidian local state: created when you open vault/ in Obsidian (UI config,
34
+ # sideloaded plugin copies). Local-only; the vault is valid without it.
35
+ vault/.obsidian/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptoria
3
- Version: 0.4.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.4.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.4.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
- for line in path.read_text(encoding="utf-8").splitlines():
116
- m = _FOOTNOTE.match(line)
117
- if not m:
118
- continue
119
- target = m.group(2)
120
- if "#" not in target:
121
- continue
122
- source_id, anchor = target.split("#", 1)
123
- yield {"fn": m.group(1), "source_id": source_id, "anchor": anchor}
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
- for lineno, raw_line in enumerate(
156
- claims_path.read_text(encoding="utf-8").splitlines(), start=1
157
- ):
158
- line = raw_line.strip()
159
- if not line:
160
- continue
161
- try:
162
- rec = json.loads(line)
163
- except json.JSONDecodeError as e:
164
- raise DataError(f"claims.ndjson:{lineno}: invalid JSON: {e}") from e
165
- for key in ("claim_id", "source_id", "anchor"):
166
- if key not in rec:
167
- raise DataError(f"claims.ndjson:{lineno}: missing '{key}'")
168
- cid = rec["claim_id"]
169
- if cid in seen:
170
- raise DataError(f"duplicate claim_id: {cid}")
171
- seen.add(cid)
172
- text = _source_text(root, rec["source_id"], src_cache)
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 = r.get("claim_id") or r.get("entity_id") or f"{r['src']} -> {r['dst']}"
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(rec: dict, index: int, required: tuple[str, ...], allowed: frozenset[str]) -> None:
100
- owned = [k for k in _SCRIP_OWNED if k in rec]
101
- if owned:
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(owned)} itself — propose a "
104
- f"verbatim 'quote', not precomputed ids/anchors/timestamps"
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
- else: # edges
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 _read_table(path: Path) -> tuple[list[dict], str]:
203
- """Read an NDJSON table, returning ``(records, raw_text)``. Malformed lines
204
- are a :class:`DataError` — the vault on disk violates the contract."""
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
- text = path.read_text(encoding="utf-8")
246
+ return [], True
247
+ ends_newline = _ends_with_newline(path)
208
248
  records: list[dict] = []
209
- for lineno, raw_line in enumerate(text.splitlines(), start=1):
210
- line = raw_line.strip()
211
- if not line:
212
- continue
213
- try:
214
- rec = json.loads(line)
215
- except json.JSONDecodeError as e:
216
- raise DataError(f"{path.name}:{lineno}: invalid JSON: {e}") from e
217
- records.append(rec)
218
- return records, text
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, existing_text = _read_table(path)
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
- else: # edges
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 existing_text and not existing_text.endswith("\n"):
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
- did = meta.get("id") or str(path.relative_to(root))
83
+ where = str(path.relative_to(root))
84
+ did = frontmatter.as_str(meta, "id", where) or where
84
85
  out[did] = {
85
- "path": str(path.relative_to(root)),
86
- "type": meta.get("type", "wiki"),
87
- "derived_from": list(meta.get("derived-from") or []),
88
- "input_hash": meta.get("input-hash"),
89
- "last_compiled": meta.get("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")) or {}
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 isinstance(data, dict) and "derived-from" in data:
98
- did = data.get("id") or "facts/core"
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": str(fmeta.relative_to(root)),
101
- "type": data.get("type", "facts.set"),
102
- "derived_from": list(data.get("derived-from") or []),
103
- "input_hash": data.get("input-hash"),
104
- "last_compiled": data.get("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
- for b in r["blocks"]:
118
- if b["block_id"] == block_id:
119
- return b["hash"]
120
- return None
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
- t = _WS.sub(" ", t).strip()
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. Detection is deterministic; adjudication is the
33
- # agent's job.
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