scriptoria 0.7.0__tar.gz → 0.8.0__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 (50) hide show
  1. {scriptoria-0.7.0 → scriptoria-0.8.0}/PKG-INFO +1 -1
  2. {scriptoria-0.7.0 → scriptoria-0.8.0}/pyproject.toml +1 -1
  3. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/__init__.py +1 -1
  4. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/anchors.py +21 -0
  5. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/facts.py +33 -10
  6. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_fact_cmd.py +73 -0
  7. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_verify.py +38 -0
  8. {scriptoria-0.7.0 → scriptoria-0.8.0}/uv.lock +1 -1
  9. {scriptoria-0.7.0 → scriptoria-0.8.0}/.gitignore +0 -0
  10. {scriptoria-0.7.0 → scriptoria-0.8.0}/README.md +0 -0
  11. {scriptoria-0.7.0 → scriptoria-0.8.0}/pyrightconfig.json +0 -0
  12. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/blocks.py +0 -0
  13. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/cli.py +0 -0
  14. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/embeddings.py +0 -0
  15. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/errors.py +0 -0
  16. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/frontmatter.py +0 -0
  17. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/graph.py +0 -0
  18. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/hashing.py +0 -0
  19. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/ingest.py +0 -0
  20. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/lock.py +0 -0
  21. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/manifest.py +0 -0
  22. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/pageindex_adapter.py +0 -0
  23. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/query.py +0 -0
  24. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/retrieval.py +0 -0
  25. {scriptoria-0.7.0 → scriptoria-0.8.0}/src/scrip/similar.py +0 -0
  26. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/conftest.py +0 -0
  27. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_anchor_cmd.py +0 -0
  28. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_anchors.py +0 -0
  29. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_blocks.py +0 -0
  30. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_embeddings.py +0 -0
  31. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_facts.py +0 -0
  32. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_frontmatter.py +0 -0
  33. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_graph_status.py +0 -0
  34. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_hashing.py +0 -0
  35. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_index_cmd.py +0 -0
  36. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_ingest.py +0 -0
  37. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_json_shapes.py +0 -0
  38. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_lock.py +0 -0
  39. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_manifest.py +0 -0
  40. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_new_cmd.py +0 -0
  41. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_pageindex_cmd.py +0 -0
  42. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_query.py +0 -0
  43. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_retrieval.py +0 -0
  44. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_similar_cmd.py +0 -0
  45. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_span_cmd.py +0 -0
  46. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_stamp.py +0 -0
  47. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_status_cmd.py +0 -0
  48. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_unlock_cmd.py +0 -0
  49. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_version.py +0 -0
  50. {scriptoria-0.7.0 → scriptoria-0.8.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptoria
3
- Version: 0.7.0
3
+ Version: 0.8.0
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.7.0"
5
+ version = "0.8.0"
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.7.0"
16
+ __version__ = "0.8.0"
17
17
 
18
18
  # --- canonical vault layout ------------------------------------------------
19
19
  # ``root`` is the repo/instance root: the directory containing ``vault/``.
@@ -199,6 +199,27 @@ def verify_vault(root: Path) -> dict:
199
199
  text = _source_text(root, rec["source_id"], src_cache)
200
200
  record(f"claim:{cid}", rec, resolve(text, rec["anchor"]))
201
201
 
202
+ # cited edges (graph.ndjson): an edge that carries an anchor is verified like
203
+ # a claim; a bare {src,dst,kind} edge has no citation to resolve and is skipped.
204
+ edges_path = facts_dir(root) / "graph.ndjson"
205
+ if edges_path.exists():
206
+ with edges_path.open(encoding="utf-8") as f:
207
+ for lineno, raw_line in enumerate(f, start=1):
208
+ line = raw_line.strip()
209
+ if not line:
210
+ continue
211
+ try:
212
+ rec = json.loads(line)
213
+ except json.JSONDecodeError as e:
214
+ raise DataError(f"graph.ndjson:{lineno}: invalid JSON: {e}") from e
215
+ if "anchor" not in rec:
216
+ continue
217
+ if "source_id" not in rec:
218
+ raise DataError(f"graph.ndjson:{lineno}: cited edge missing 'source_id'")
219
+ text = _source_text(root, rec["source_id"], src_cache)
220
+ where = f"edge:{rec.get('src')}->{rec.get('dst')} ({rec.get('kind')})"
221
+ record(where, rec, resolve(text, rec["anchor"]))
222
+
202
223
  wd = wiki_dir(root)
203
224
  if wd.is_dir():
204
225
  for path in sorted(wd.rglob("*.md")):
@@ -53,7 +53,10 @@ _CLAIM_ALLOWED = frozenset((*_CLAIM_REQUIRED, "claim_text", "tags"))
53
53
  _ENTITY_REQUIRED = ("entity_id", "name", "kind")
54
54
  _ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags"))
55
55
  _EDGE_REQUIRED = ("src", "dst", "kind")
56
- _EDGE_ALLOWED = frozenset(_EDGE_REQUIRED)
56
+ # An edge may optionally be *cited*: a verbatim ``quote`` + ``source_id`` whose
57
+ # ``anchor`` scrip mints+verifies exactly as for a claim. Bare edges stay
58
+ # structural. ``anchor`` remains scrip-owned (proposing it is a schema error).
59
+ _EDGE_ALLOWED = frozenset((*_EDGE_REQUIRED, "quote", "source_id"))
57
60
  _DECISIONS = ("supersede", "qualify", "keep-both")
58
61
  _RECON_REQUIRED = ("decision", "claim_a", "claim_b")
59
62
  _RECON_ALLOWED = frozenset((*_RECON_REQUIRED, "winner", "rationale"))
@@ -161,6 +164,17 @@ def _validate(table: str, rec: dict, index: int) -> None:
161
164
  _check_shape(rec, index, _EDGE_REQUIRED, _EDGE_ALLOWED)
162
165
  for key in _EDGE_REQUIRED:
163
166
  _check_str(rec, key, index)
167
+ if "quote" in rec or "source_id" in rec:
168
+ # both or neither: a quote needs a source to anchor against
169
+ missing = [k for k in ("quote", "source_id") if k not in rec]
170
+ if missing:
171
+ raise DataError(
172
+ f"record {index}: a cited edge needs both 'quote' and 'source_id' "
173
+ f"(missing: {', '.join(missing)})"
174
+ )
175
+ _check_str(rec, "source_id", index)
176
+ # quote emptiness is a per-record finding (EMPTY_QUOTE), not a schema error
177
+ _check_str(rec, "quote", index, allow_blank=True)
164
178
  else: # reconciliations
165
179
  _check_shape(rec, index, _RECON_REQUIRED, _RECON_ALLOWED, owned=_RECON_OWNED)
166
180
  for key in ("decision", "claim_a", "claim_b"):
@@ -390,13 +404,16 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
390
404
  resolved: list[dict | None] = [None] * len(proposals)
391
405
  path = facts_dir(root) / _FILES[table]
392
406
  with lock.write_lock(root):
393
- if table == "claims":
394
- # Resolve quotes INSIDE the lock: raw/ only changes via a *locked*
395
- # `ingest --reingest`, so holding the lock from verification through
396
- # append closes the window where a re-ingest could land between the
397
- # two and silently break the just-minted anchors.
407
+ # Resolve quotes INSIDE the lock: raw/ only changes via a *locked*
408
+ # `ingest --reingest`, so holding the lock from verification through
409
+ # append closes the window where a re-ingest could land between the
410
+ # two and silently break the just-minted anchors. Claims always carry a
411
+ # quote; an edge does only when it is *cited*.
412
+ if table == "claims" or table == "edges":
398
413
  src_cache: dict[str, str | None] = {}
399
414
  for i, rec in enumerate(proposals):
415
+ if table == "edges" and "quote" not in rec:
416
+ continue # bare edge: nothing to anchor
400
417
  fail, res = _resolve_claim(root, rec, i, src_cache)
401
418
  if fail:
402
419
  failures.append(fail)
@@ -478,7 +495,12 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
478
495
  skipped.append({"index": i, "reason": "duplicate", "existing_id": None})
479
496
  continue
480
497
  seen_edges.add(key)
481
- appended.append({"src": rec["src"], "dst": rec["dst"], "kind": rec["kind"]})
498
+ full = {"src": rec["src"], "dst": rec["dst"], "kind": rec["kind"]}
499
+ res = resolved[i]
500
+ if res is not None: # cited edge: carry the minted provenance
501
+ full["source_id"] = res["source_id"]
502
+ full["anchor"] = res["anchor"]
503
+ appended.append(full)
482
504
  else: # reconciliations
483
505
  claim_ids = {c.get("claim_id") for c in _read_table(facts_dir(root) / "claims.ndjson")[0]}
484
506
  for i, rec in enumerate(proposals):
@@ -528,10 +550,11 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
528
550
  f.write("\n")
529
551
  f.write(payload)
530
552
  new_sources: list[str] = []
531
- if table == "claims":
553
+ if table in ("claims", "edges"):
532
554
  for rec in appended:
533
- if rec["source_id"] not in new_sources:
534
- new_sources.append(rec["source_id"])
555
+ sid = rec.get("source_id") # cited edges carry one; bare edges don't
556
+ if sid and sid not in new_sources:
557
+ new_sources.append(sid)
535
558
  _write_meta(root, meta, new_sources)
536
559
 
537
560
  return {"table": table, "appended": appended, "skipped": skipped, "failures": []}
@@ -31,6 +31,12 @@ def _claim(quote, source="raw/s", **kw):
31
31
  return rec
32
32
 
33
33
 
34
+ def _edge(src="entity/a", dst="entity/b", kind="relates-to", **kw):
35
+ rec = {"src": src, "dst": dst, "kind": kind}
36
+ rec.update(kw)
37
+ return rec
38
+
39
+
34
40
  def _ndjson(*recs):
35
41
  return "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in recs)
36
42
 
@@ -55,6 +61,13 @@ def _recs_lines(kb):
55
61
  return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
56
62
 
57
63
 
64
+ def _graph_lines(kb):
65
+ p = kb.root / "vault" / "facts" / "graph.ndjson"
66
+ if not p.exists():
67
+ return []
68
+ return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
69
+
70
+
58
71
  def _two_claims(kb):
59
72
  """Seed a contradiction pair to reconcile."""
60
73
  kb.add_raw("s", SRC)
@@ -476,3 +489,63 @@ def test_fact_add_edges_appends_and_skips_duplicates(kb):
476
489
  assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0
477
490
  lines = (kb.root / "vault" / "facts" / "graph.ndjson").read_text(encoding="utf-8")
478
491
  assert len(lines.splitlines()) == 1
492
+
493
+
494
+ # --------------------------------------------------------------------------- #
495
+ # Cited edges: an edge may carry a verbatim quote + source_id; scrip mints and
496
+ # verifies an anchor for it exactly as it does for a claim. Bare edges still work.
497
+ # --------------------------------------------------------------------------- #
498
+ def test_fact_add_bare_edge_carries_no_provenance(kb):
499
+ # additivity guard: an edge with no quote stays purely structural
500
+ assert _run_add(kb, _ndjson(_edge()), "--table", "edges") == 0
501
+ [edge] = _graph_lines(kb)
502
+ assert set(edge) == {"src", "dst", "kind"}
503
+
504
+
505
+ def test_fact_add_cited_edge_mints_and_verifies_anchor(kb):
506
+ kb.add_raw("s", SRC)
507
+ rc = _run_add(
508
+ kb,
509
+ _ndjson(_edge(quote="Caching answers beats recomputing them.", source_id="raw/s")),
510
+ "--table", "edges",
511
+ )
512
+ assert rc == 0
513
+ [edge] = _graph_lines(kb)
514
+ assert (edge["src"], edge["dst"], edge["kind"]) == ("entity/a", "entity/b", "relates-to")
515
+ assert edge["source_id"] == "raw/s"
516
+ # the minted anchor resolves uniquely in the source, and scrip verify confirms it
517
+ assert anchors.resolve(SRC, edge["anchor"]) == "OK"
518
+ assert cli.main(["verify", "--root", str(kb.root)]) == 0
519
+
520
+
521
+ def test_fact_add_cited_edge_broken_quote_fails_batch(kb, capsys):
522
+ kb.add_raw("s", SRC)
523
+ rc = _run_add(
524
+ kb,
525
+ _ndjson(_edge(quote="this sentence is absent from the source", source_id="raw/s")),
526
+ "--table", "edges", "--json",
527
+ )
528
+ assert rc == 1
529
+ assert _graph_lines(kb) == [] # all-or-nothing: nothing written
530
+ [failure] = json.loads(capsys.readouterr().out)["failures"]
531
+ assert failure["status"] == "BROKEN"
532
+
533
+
534
+ def test_fact_add_cited_edge_requires_both_quote_and_source_id(kb):
535
+ kb.add_raw("s", SRC)
536
+ # a quote needs a source to anchor against, and vice versa — both or neither
537
+ assert _run_add(kb, _ndjson(_edge(quote="Caching answers beats recomputing them.")),
538
+ "--table", "edges") == 3
539
+ assert _run_add(kb, _ndjson(_edge(source_id="raw/s")), "--table", "edges") == 3
540
+ assert _graph_lines(kb) == []
541
+
542
+
543
+ def test_fact_add_cited_edge_dedups_on_triple(kb, capsys):
544
+ kb.add_raw("s", SRC)
545
+ cited = _ndjson(_edge(quote="Caching answers beats recomputing them.", source_id="raw/s"))
546
+ assert _run_add(kb, cited, "--table", "edges") == 0
547
+ capsys.readouterr()
548
+ # same (src,dst,kind) again — even bare — is the same relation, so it's skipped
549
+ assert _run_add(kb, _ndjson(_edge()), "--table", "edges", "--json") == 0
550
+ assert len(_graph_lines(kb)) == 1
551
+ assert json.loads(capsys.readouterr().out)["skipped"][0]["reason"] == "duplicate"
@@ -1,9 +1,17 @@
1
+ import json
2
+
1
3
  import pytest
2
4
  from scrip.errors import DataError
3
5
 
4
6
  from scrip import anchors, cli
5
7
 
6
8
 
9
+ def _write_edge(kb, rec):
10
+ p = kb.root / "vault" / "facts" / "graph.ndjson"
11
+ with open(p, "a", encoding="utf-8") as f:
12
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
13
+
14
+
7
15
  def test_cli_verify_fails_on_ambiguous_by_default(kb):
8
16
  # a quote that occurs twice in the source -> AMBIGUOUS
9
17
  kb.add_raw("a", "# A\n\nalpha beta gamma. alpha beta gamma.\n")
@@ -62,3 +70,33 @@ def test_verify_wiki_footnote_anchor(kb):
62
70
  res = anchors.verify_vault(kb.root)
63
71
  assert res["ok"] == 1
64
72
  assert res["broken"] == []
73
+
74
+
75
+ def test_verify_cited_edge_anchor_resolves(kb):
76
+ src = "# A\n\nThe sky is blue today over the hills.\n"
77
+ kb.add_raw("a", src)
78
+ anchor = anchors.make_anchor(src, "the sky is blue today")
79
+ _write_edge(kb, {"src": "entity/sky", "dst": "entity/color", "kind": "is",
80
+ "source_id": "raw/a", "anchor": anchor})
81
+ res = anchors.verify_vault(kb.root)
82
+ assert res["ok"] == 1
83
+ assert res["broken"] == []
84
+
85
+
86
+ def test_verify_broken_cited_edge_anchor_is_listed(kb):
87
+ kb.add_raw("a", "# A\n\nThe sky is blue.\n")
88
+ bogus = "qh:" + ("0" * 64) + "|loc:0.0|len:10"
89
+ _write_edge(kb, {"src": "entity/a", "dst": "entity/b", "kind": "rel",
90
+ "source_id": "raw/a", "anchor": bogus})
91
+ res = anchors.verify_vault(kb.root)
92
+ assert len(res["broken"]) == 1
93
+ assert res["broken"][0]["where"].startswith("edge:")
94
+
95
+
96
+ def test_verify_bare_edge_is_not_checked(kb):
97
+ # an uncited edge has nothing to resolve, so verify ignores it (additive)
98
+ kb.add_raw("a", "# A\n\nThe sky is blue.\n")
99
+ _write_edge(kb, {"src": "entity/a", "dst": "entity/b", "kind": "rel"})
100
+ res = anchors.verify_vault(kb.root)
101
+ assert res["checked"] == 0
102
+ assert res["broken"] == []
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.7.0"
1144
+ version = "0.8.0"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
File without changes
File without changes
File without changes
File without changes
File without changes