scriptoria 0.8.1__tar.gz → 0.10.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 (52) hide show
  1. {scriptoria-0.8.1 → scriptoria-0.10.0}/PKG-INFO +6 -3
  2. {scriptoria-0.8.1 → scriptoria-0.10.0}/README.md +4 -1
  3. {scriptoria-0.8.1 → scriptoria-0.10.0}/pyproject.toml +1 -1
  4. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/__init__.py +1 -1
  5. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/blocks.py +29 -27
  6. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/cli.py +106 -6
  7. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/facts.py +43 -12
  8. scriptoria-0.10.0/src/scrip/ontology.py +162 -0
  9. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_fact_cmd.py +130 -0
  10. scriptoria-0.10.0/tests/test_init_cmd.py +102 -0
  11. {scriptoria-0.8.1 → scriptoria-0.10.0}/uv.lock +2 -2
  12. {scriptoria-0.8.1 → scriptoria-0.10.0}/.gitignore +0 -0
  13. {scriptoria-0.8.1 → scriptoria-0.10.0}/pyrightconfig.json +0 -0
  14. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/anchors.py +0 -0
  15. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/embeddings.py +0 -0
  16. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/errors.py +0 -0
  17. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/frontmatter.py +0 -0
  18. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/graph.py +0 -0
  19. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/hashing.py +0 -0
  20. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/ingest.py +0 -0
  21. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/lock.py +0 -0
  22. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/manifest.py +0 -0
  23. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/pageindex_adapter.py +0 -0
  24. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/query.py +0 -0
  25. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/retrieval.py +0 -0
  26. {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/similar.py +0 -0
  27. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/conftest.py +0 -0
  28. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_anchor_cmd.py +0 -0
  29. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_anchors.py +0 -0
  30. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_blocks.py +0 -0
  31. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_embeddings.py +0 -0
  32. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_facts.py +0 -0
  33. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_frontmatter.py +0 -0
  34. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_graph_status.py +0 -0
  35. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_hashing.py +0 -0
  36. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_index_cmd.py +0 -0
  37. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_ingest.py +0 -0
  38. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_json_shapes.py +0 -0
  39. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_lock.py +0 -0
  40. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_manifest.py +0 -0
  41. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_new_cmd.py +0 -0
  42. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_pageindex_cmd.py +0 -0
  43. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_query.py +0 -0
  44. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_retrieval.py +0 -0
  45. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_similar_cmd.py +0 -0
  46. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_span_cmd.py +0 -0
  47. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_stamp.py +0 -0
  48. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_status_cmd.py +0 -0
  49. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_unlock_cmd.py +0 -0
  50. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_verify.py +0 -0
  51. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_version.py +0 -0
  52. {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: scriptoria
3
- Version: 0.8.1
3
+ Version: 0.10.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
@@ -51,9 +51,11 @@ uv run --project scrip scrip --help
51
51
 
52
52
  | Command | What it does |
53
53
  |---|---|
54
+ | `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
54
55
  | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
55
56
  | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
56
57
  | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
58
+ | `scrip ontology` | Validate and summarize optional `vault/ontology.yaml` vocabulary for fact predicates, entity kinds, and edge kinds. |
57
59
  | `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
58
60
  | `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
59
61
  | `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
@@ -66,7 +68,8 @@ uv run --project scrip scrip --help
66
68
  | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
67
69
  | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
68
70
 
69
- Every command accepts `--root DIR` and `--json`.
71
+ Every command accepts `--root DIR` and `--json` (`init` takes its target as a
72
+ positional path instead of `--root`).
70
73
 
71
74
  The optional `scrip-harness` package adds the model-bearing commands, including
72
75
  `scrip-harness answer "<question>"`, which gathers evidence with these primitives
@@ -20,9 +20,11 @@ uv run --project scrip scrip --help
20
20
 
21
21
  | Command | What it does |
22
22
  |---|---|
23
+ | `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
23
24
  | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
24
25
  | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
25
26
  | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
27
+ | `scrip ontology` | Validate and summarize optional `vault/ontology.yaml` vocabulary for fact predicates, entity kinds, and edge kinds. |
26
28
  | `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
27
29
  | `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
28
30
  | `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
@@ -35,7 +37,8 @@ uv run --project scrip scrip --help
35
37
  | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
36
38
  | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
37
39
 
38
- Every command accepts `--root DIR` and `--json`.
40
+ Every command accepts `--root DIR` and `--json` (`init` takes its target as a
41
+ positional path instead of `--root`).
39
42
 
40
43
  The optional `scrip-harness` package adds the model-bearing commands, including
41
44
  `scrip-harness answer "<question>"`, which gathers evidence with these primitives
@@ -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.8.1"
5
+ version = "0.10.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.8.1"
16
+ __version__ = "0.10.0"
17
17
 
18
18
  # --- canonical vault layout ------------------------------------------------
19
19
  # ``root`` is the repo/instance root: the directory containing ``vault/``.
@@ -38,43 +38,45 @@ def split_blocks(text: str) -> list[dict]:
38
38
  # Use splitlines(keepends=True): its boundary set (form feed, NEL, U+2028/9,
39
39
  # …) is part of the deterministic segmentation contract — io.StringIO only
40
40
  # splits on \r/\n and would re-hash existing blocks that contain those chars.
41
- ranges: list[list[int]] = [] # [start, end] per block
42
- cur: list[int] | None = None
41
+ # Emit blocks in a single pass: a running [cur_start, cur_end] paragraph is
42
+ # flushed at each boundary, so no intermediate `ranges` list is allocated.
43
+ out: list[dict] = []
44
+ seen: dict[str, int] = {}
45
+
46
+ def emit(s: int, e: int) -> None:
47
+ slice_text = text[s:e]
48
+ out.append(
49
+ {
50
+ "block_id": _block_id(slice_text, seen),
51
+ "span": [s, e],
52
+ "hash": hashing.sha256_text(slice_text),
53
+ }
54
+ )
55
+
56
+ cur_start: int | None = None
57
+ cur_end = 0
43
58
  start = 0
44
59
 
45
60
  for line in text.splitlines(keepends=True):
46
61
  end = start + len(line)
47
62
  if _is_blank(line):
48
- if cur is not None:
49
- ranges.append(cur)
50
- cur = None
63
+ if cur_start is not None:
64
+ emit(cur_start, cur_end)
65
+ cur_start = None
51
66
  elif _is_heading(line):
52
- if cur is not None:
53
- ranges.append(cur)
54
- cur = None
55
- ranges.append([start, end]) # a heading is its own block
67
+ if cur_start is not None:
68
+ emit(cur_start, cur_end)
69
+ cur_start = None
70
+ emit(start, end) # a heading is its own block
56
71
  else:
57
- if cur is None:
58
- cur = [start, end]
59
- else:
60
- cur[1] = end
72
+ if cur_start is None:
73
+ cur_start = start
74
+ cur_end = end
61
75
  start = end
62
76
 
63
- if cur is not None:
64
- ranges.append(cur)
77
+ if cur_start is not None:
78
+ emit(cur_start, cur_end)
65
79
 
66
- out: list[dict] = []
67
- seen: dict[str, int] = {}
68
- for s, e in ranges:
69
- slice_text = text[s:e]
70
- block_id = _block_id(slice_text, seen)
71
- out.append(
72
- {
73
- "block_id": block_id,
74
- "span": [s, e],
75
- "hash": hashing.sha256_text(slice_text),
76
- }
77
- )
78
80
  return out
79
81
 
80
82
 
@@ -35,12 +35,20 @@ def _safe_slug(slug: str, what: str = "slug") -> str:
35
35
  # --------------------------------------------------------------------------- #
36
36
  # Root resolution
37
37
  # --------------------------------------------------------------------------- #
38
+ def _looks_like_root(cand: Path) -> bool:
39
+ """True if ``cand`` looks like a scriptorium instance root: a directory
40
+ containing ``vault/`` plus either ``SPEC.md`` or ``.kb/``."""
41
+ return (cand / "vault").is_dir() and (
42
+ (cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
43
+ )
44
+
45
+
38
46
  def resolve_root(root_arg: str | None) -> Path:
39
47
  """Return the scriptorium root.
40
48
 
41
49
  With ``--root`` the path is used verbatim (must be a directory). Otherwise we
42
- walk up from the cwd for the nearest ancestor that looks like an instance: a
43
- directory containing ``vault/`` plus either ``SPEC.md`` or ``.kb/``.
50
+ walk up from the cwd for the nearest ancestor that looks like an instance
51
+ (see :func:`_looks_like_root`).
44
52
  """
45
53
  if root_arg:
46
54
  root = Path(root_arg).expanduser().resolve()
@@ -50,13 +58,12 @@ def resolve_root(root_arg: str | None) -> Path:
50
58
 
51
59
  cur = Path.cwd().resolve()
52
60
  for cand in (cur, *cur.parents):
53
- if (cand / "vault").is_dir() and (
54
- (cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
55
- ):
61
+ if _looks_like_root(cand):
56
62
  return cand
57
63
  raise errors.UsageError(
58
64
  "could not locate a scriptorium root (looked for a parent with vault/ and "
59
- "SPEC.md or .kb/). Pass --root explicitly."
65
+ "SPEC.md or .kb/). Run `scrip init` to start one here, or pass --root "
66
+ "explicitly."
60
67
  )
61
68
 
62
69
 
@@ -68,6 +75,58 @@ def _emit(payload: dict) -> None:
68
75
  # --------------------------------------------------------------------------- #
69
76
  # Subcommand handlers -> return an int exit code
70
77
  # --------------------------------------------------------------------------- #
78
+ def cmd_init(args: argparse.Namespace) -> int:
79
+ from . import graph, manifest_path
80
+
81
+ target = Path(args.path or ".").expanduser().resolve()
82
+ if target.exists() and not target.is_dir():
83
+ raise errors.UsageError(f"init target is not a directory: {target}")
84
+ if _looks_like_root(target):
85
+ if args.json:
86
+ _emit({"root": str(target), "status": "exists", "created": []})
87
+ else:
88
+ print(f"already a scriptorium root: {target} (nothing to do)")
89
+ return 0
90
+ # Refuse to create a nested instance: an inner vault/ would shadow the outer
91
+ # root for every command run beneath it (resolve_root stops at the nearest
92
+ # ancestor).
93
+ for cand in target.parents:
94
+ if _looks_like_root(cand):
95
+ raise errors.UsageError(
96
+ f"refusing to create a nested scriptorium: {target} is inside the "
97
+ f"root at {cand}. Run `scrip init` at that root, or pick a "
98
+ f"directory outside it."
99
+ )
100
+
101
+ created: list[str] = []
102
+ for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
103
+ d = target / rel
104
+ if d.is_dir():
105
+ continue
106
+ if d.exists():
107
+ raise errors.UsageError(
108
+ f"cannot create {rel}/: a file already exists at {d}"
109
+ )
110
+ d.mkdir(parents=True, exist_ok=True)
111
+ created.append(rel + "/")
112
+ # Same as `scrip status --rebuild-manifest`: scan the (empty) vault and write
113
+ # the .kb/manifest.json cache, so the instance starts warm and detectable.
114
+ graph.compute_status(target, rebuild=True)
115
+ created.append(str(manifest_path(target).relative_to(target)))
116
+
117
+ if args.json:
118
+ _emit({"root": str(target), "status": "initialized", "created": created})
119
+ else:
120
+ print(f"initialized scriptorium root at {target}")
121
+ for c in created:
122
+ print(f" + {c}")
123
+ print(
124
+ " next: add a source (`scrip ingest <url|file>`), compile a page "
125
+ "(`scrip new` + `scrip anchor` + `scrip stamp`), then `scrip verify`"
126
+ )
127
+ return 0
128
+
129
+
71
130
  def cmd_status(args: argparse.Namespace) -> int:
72
131
  from . import graph
73
132
 
@@ -493,6 +552,25 @@ def cmd_fact_add(args: argparse.Namespace) -> int:
493
552
  return 1 if result["failures"] else 0
494
553
 
495
554
 
555
+ def cmd_ontology(args: argparse.Namespace) -> int:
556
+ from . import ontology
557
+
558
+ root = resolve_root(args.root)
559
+ ont = ontology.load(root)
560
+ result = {"path": str(ontology.ontology_path(root).relative_to(root)), **ont.summary()}
561
+ if args.json:
562
+ _emit(result)
563
+ else:
564
+ state = "active" if ont.active else "not present or empty"
565
+ print(f"ontology: {state}")
566
+ if ont.active:
567
+ print(f" entity kinds: {', '.join(result['entity_kinds']) or '(none)'}")
568
+ print(f" edge kinds: {', '.join(result['edge_kinds']) or '(none)'}")
569
+ print(f" claim predicates: {', '.join(result['claim_predicates']) or '(none)'}")
570
+ print(f" predicate aliases: {len(result['predicate_aliases'])}")
571
+ return 0
572
+
573
+
496
574
  def cmd_ingest(args: argparse.Namespace) -> int:
497
575
  from . import ingest, lock
498
576
 
@@ -536,6 +614,21 @@ def build_parser() -> argparse.ArgumentParser:
536
614
  p.add_argument("--version", action="version", version=f"scrip {__version__}")
537
615
  sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
538
616
 
617
+ pinit = sub.add_parser(
618
+ "init",
619
+ help="create the root skeleton (vault/{raw,facts,wiki}/ + .kb/ cache) and its manifest",
620
+ )
621
+ pinit.add_argument(
622
+ "path",
623
+ nargs="?",
624
+ metavar="DIR",
625
+ help="directory to initialize (default: current directory; created if absent)",
626
+ )
627
+ pinit.add_argument(
628
+ "--json", action="store_true", help="machine-readable JSON output"
629
+ )
630
+ pinit.set_defaults(func=cmd_init)
631
+
539
632
  ps = sub.add_parser(
540
633
  "status",
541
634
  parents=[common],
@@ -764,6 +857,13 @@ def build_parser() -> argparse.ArgumentParser:
764
857
  )
765
858
  pfa.set_defaults(func=cmd_fact_add)
766
859
 
860
+ po = sub.add_parser(
861
+ "ontology",
862
+ parents=[common],
863
+ help="validate and summarize optional vault/ontology.yaml vocabulary",
864
+ )
865
+ po.set_defaults(func=cmd_ontology)
866
+
767
867
  pin = sub.add_parser(
768
868
  "ingest",
769
869
  parents=[common],
@@ -34,6 +34,8 @@ except ImportError:
34
34
 
35
35
  from . import anchors, facts_dir, lock
36
36
  from .errors import DataError, UsageError
37
+ from .ontology import Ontology
38
+ from .ontology import load as load_ontology
37
39
 
38
40
  _POLARITIES = ("asserts", "denies", "qualifies")
39
41
 
@@ -52,7 +54,7 @@ _RECON_OWNED = ("reconciliation_id", "at")
52
54
  _CLAIM_REQUIRED = ("quote", "source_id", "subject", "predicate", "object", "polarity", "confidence")
53
55
  _CLAIM_ALLOWED = frozenset((*_CLAIM_REQUIRED, "claim_text", "tags"))
54
56
  _ENTITY_REQUIRED = ("entity_id", "name", "kind")
55
- _ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags"))
57
+ _ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags", "uri", "same_as", "external_ids"))
56
58
  _EDGE_REQUIRED = ("src", "dst", "kind")
57
59
  # An edge may optionally be *cited*: a verbatim ``quote`` + ``source_id`` whose
58
60
  # ``anchor`` scrip mints+verifies exactly as for a claim. Bare edges stay
@@ -127,6 +129,26 @@ def _check_tags(rec: dict, index: int) -> None:
127
129
  raise DataError(f"record {index}: 'tags' must be a list of strings")
128
130
 
129
131
 
132
+ def _check_string_list(rec: dict, key: str, index: int) -> None:
133
+ values = rec.get(key)
134
+ if values is None:
135
+ return
136
+ if not isinstance(values, list) or any(not isinstance(v, str) or not v.strip() for v in values):
137
+ raise DataError(f"record {index}: '{key}' must be a list of non-empty strings")
138
+
139
+
140
+ def _check_string_map(rec: dict, key: str, index: int) -> None:
141
+ values = rec.get(key)
142
+ if values is None:
143
+ return
144
+ if (
145
+ not isinstance(values, dict)
146
+ or any(not isinstance(k, str) or not k.strip() for k in values)
147
+ or any(not isinstance(v, str) or not v.strip() for v in values.values())
148
+ ):
149
+ raise DataError(f"record {index}: '{key}' must be a mapping of strings to strings")
150
+
151
+
130
152
  def _check_shape(
131
153
  rec: dict,
132
154
  index: int,
@@ -148,13 +170,14 @@ def _check_shape(
148
170
  raise DataError(f"record {index}: missing required field(s): {', '.join(missing)}")
149
171
 
150
172
 
151
- def _validate(table: str, rec: dict, index: int) -> None:
173
+ def _validate(table: str, rec: dict, index: int, ont: Ontology) -> None:
152
174
  if table == "claims":
153
175
  _check_shape(rec, index, _CLAIM_REQUIRED, _CLAIM_ALLOWED)
154
176
  # the quote's *emptiness* is a per-record finding, not a schema error
155
177
  _check_str(rec, "quote", index, allow_blank=True)
156
178
  for key in ("source_id", "subject", "predicate", "object"):
157
179
  _check_str(rec, key, index)
180
+ rec["predicate"] = ont.canonical_claim_predicate(rec["predicate"], index)
158
181
  if "claim_text" in rec:
159
182
  _check_str(rec, "claim_text", index, allow_blank=True)
160
183
  if rec["polarity"] not in _POLARITIES:
@@ -169,14 +192,20 @@ def _validate(table: str, rec: dict, index: int) -> None:
169
192
  _check_shape(rec, index, _ENTITY_REQUIRED, _ENTITY_ALLOWED)
170
193
  for key in _ENTITY_REQUIRED:
171
194
  _check_str(rec, key, index)
195
+ ont.validate_entity_kind(rec["kind"], index)
172
196
  eid = rec["entity_id"]
173
197
  if not (eid.startswith("entity/") and _SLUG_RE.fullmatch(eid[len("entity/") :])):
174
198
  raise DataError(f"record {index}: entity_id must look like entity/<slug>")
199
+ if "uri" in rec:
200
+ _check_str(rec, "uri", index)
201
+ _check_string_list(rec, "same_as", index)
202
+ _check_string_map(rec, "external_ids", index)
175
203
  _check_tags(rec, index)
176
204
  elif table == "edges":
177
205
  _check_shape(rec, index, _EDGE_REQUIRED, _EDGE_ALLOWED)
178
206
  for key in _EDGE_REQUIRED:
179
207
  _check_str(rec, key, index)
208
+ ont.validate_edge_kind(rec["kind"], index)
180
209
  if "quote" in rec or "source_id" in rec:
181
210
  # both or neither: a quote needs a source to anchor against
182
211
  missing = [k for k in ("quote", "source_id") if k not in rec]
@@ -383,10 +412,7 @@ def _load_meta(root: Path) -> dict:
383
412
 
384
413
  def _write_meta(root: Path, data: dict, new_sources: list[str]) -> None:
385
414
  derived = list(data.get("derived-from") or [])
386
- for sid in new_sources:
387
- if sid not in derived:
388
- derived.append(sid)
389
- data["derived-from"] = derived
415
+ data["derived-from"] = list(dict.fromkeys(derived + new_sources))
390
416
  # Drop the stamp on EVERY append: with an unchanged derived-from the
391
417
  # recomputed input-hash would still match the stored one, and status would
392
418
  # report OK over facts nobody has blessed. Removing input-hash forces STALE
@@ -410,8 +436,9 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
410
436
  """
411
437
  if table not in _FILES:
412
438
  raise UsageError(f"unknown facts table: {table}")
439
+ ont = load_ontology(root)
413
440
  for i, rec in enumerate(proposals):
414
- _validate(table, rec, i)
441
+ _validate(table, rec, i, ont)
415
442
 
416
443
  failures: list[dict] = []
417
444
  resolved: list[dict | None] = [None] * len(proposals)
@@ -470,12 +497,16 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
470
497
  appended.append(full)
471
498
  elif table == "entities":
472
499
  def canon(rec: dict) -> dict:
473
- return {
500
+ out = {
474
501
  "entity_id": rec["entity_id"],
475
502
  "name": rec["name"],
476
503
  "kind": rec["kind"],
477
504
  "tags": rec.get("tags") or [],
478
505
  }
506
+ for key in ("uri", "same_as", "external_ids"):
507
+ if key in rec:
508
+ out[key] = rec[key]
509
+ return out
479
510
 
480
511
  byid = {rec.get("entity_id"): canon(rec) for rec in existing if "entity_id" in rec
481
512
  and isinstance(rec.get("name"), str) and isinstance(rec.get("kind"), str)}
@@ -562,12 +593,12 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
562
593
  if not ends_with_newline:
563
594
  f.write("\n")
564
595
  f.write(payload)
565
- new_sources: list[str] = []
596
+ new_sources_dict: dict[str, None] = {}
566
597
  if table in ("claims", "edges"):
567
598
  for rec in appended:
568
599
  sid = rec.get("source_id") # cited edges carry one; bare edges don't
569
- if sid and sid not in new_sources:
570
- new_sources.append(sid)
571
- _write_meta(root, meta, new_sources)
600
+ if sid:
601
+ new_sources_dict[sid] = None
602
+ _write_meta(root, meta, list(new_sources_dict.keys()))
572
603
 
573
604
  return {"table": table, "appended": appended, "skipped": skipped, "failures": []}
@@ -0,0 +1,162 @@
1
+ """Optional semantic vocabulary for the facts layer.
2
+
3
+ The ontology is deliberately small and file-native. If ``vault/ontology.yaml`` is
4
+ absent, facts behave exactly as before. If present, it constrains selected free
5
+ text fields and can canonicalize claim predicates via aliases.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from .errors import DataError
17
+
18
+ try:
19
+ from yaml import CSafeLoader as SafeLoader
20
+ except ImportError:
21
+ from yaml import SafeLoader
22
+
23
+ _ONTOLOGY_KEYS = frozenset(
24
+ ("entity_kinds", "edge_kinds", "claim_predicates", "predicate_aliases")
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Ontology:
30
+ entity_kinds: frozenset[str] = frozenset()
31
+ edge_kinds: frozenset[str] = frozenset()
32
+ claim_predicates: frozenset[str] = frozenset()
33
+ predicate_aliases: dict[str, str] | None = None
34
+
35
+ @property
36
+ def active(self) -> bool:
37
+ return bool(
38
+ self.entity_kinds
39
+ or self.edge_kinds
40
+ or self.claim_predicates
41
+ or self.predicate_aliases
42
+ )
43
+
44
+ def canonical_claim_predicate(self, predicate: str, index: int) -> str:
45
+ aliases = self.predicate_aliases or {}
46
+ canonical = aliases.get(predicate, predicate)
47
+ if self.claim_predicates and canonical not in self.claim_predicates:
48
+ raise DataError(
49
+ f"record {index}: predicate {predicate!r} is not in "
50
+ f"vault/ontology.yaml claim_predicates"
51
+ )
52
+ return canonical
53
+
54
+ def validate_entity_kind(self, kind: str, index: int) -> None:
55
+ if self.entity_kinds and kind not in self.entity_kinds:
56
+ raise DataError(
57
+ f"record {index}: entity kind {kind!r} is not in "
58
+ f"vault/ontology.yaml entity_kinds"
59
+ )
60
+
61
+ def validate_edge_kind(self, kind: str, index: int) -> None:
62
+ if self.edge_kinds and kind not in self.edge_kinds:
63
+ raise DataError(
64
+ f"record {index}: edge kind {kind!r} is not in "
65
+ f"vault/ontology.yaml edge_kinds"
66
+ )
67
+
68
+ def summary(self) -> dict[str, Any]:
69
+ return {
70
+ "active": self.active,
71
+ "entity_kinds": sorted(self.entity_kinds),
72
+ "edge_kinds": sorted(self.edge_kinds),
73
+ "claim_predicates": sorted(self.claim_predicates),
74
+ "predicate_aliases": dict(sorted((self.predicate_aliases or {}).items())),
75
+ }
76
+
77
+
78
+ def ontology_path(root: Path) -> Path:
79
+ return root / "vault" / "ontology.yaml"
80
+
81
+
82
+ def load(root: Path) -> Ontology:
83
+ path = ontology_path(root)
84
+ if not path.exists():
85
+ return Ontology()
86
+ try:
87
+ data = yaml.load(path.read_text(encoding="utf-8"), Loader=SafeLoader)
88
+ except yaml.YAMLError as e:
89
+ raise DataError(f"invalid vault/ontology.yaml: {e}") from e
90
+ if data is None:
91
+ return Ontology()
92
+ if not isinstance(data, dict):
93
+ raise DataError("invalid vault/ontology.yaml: expected a mapping")
94
+ _check_known_keys(data)
95
+
96
+ entity_kinds = _string_set(data, "entity_kinds")
97
+ edge_kinds = _string_set(data, "edge_kinds")
98
+ claim_predicates = _string_set(data, "claim_predicates")
99
+ predicate_aliases = _string_map(data, "predicate_aliases")
100
+ if claim_predicates:
101
+ for alias, target in predicate_aliases.items():
102
+ if target not in claim_predicates:
103
+ raise DataError(
104
+ "invalid vault/ontology.yaml: predicate_aliases target "
105
+ f"{target!r} for {alias!r} is not in claim_predicates"
106
+ )
107
+
108
+ return Ontology(
109
+ entity_kinds=frozenset(entity_kinds),
110
+ edge_kinds=frozenset(edge_kinds),
111
+ claim_predicates=frozenset(claim_predicates),
112
+ predicate_aliases=predicate_aliases,
113
+ )
114
+
115
+
116
+ def _check_known_keys(data: dict) -> None:
117
+ unknown = [key for key in data if not isinstance(key, str) or key not in _ONTOLOGY_KEYS]
118
+ if unknown:
119
+ rendered = ", ".join(repr(key) for key in unknown)
120
+ expected = ", ".join(sorted(_ONTOLOGY_KEYS))
121
+ raise DataError(
122
+ f"invalid vault/ontology.yaml: unknown key(s): {rendered}; expected one of: "
123
+ f"{expected}"
124
+ )
125
+
126
+
127
+ def _string_set(data: dict, key: str) -> set[str]:
128
+ value = data.get(key, [])
129
+ if value is None:
130
+ return set()
131
+ if not isinstance(value, list):
132
+ raise DataError(f"invalid vault/ontology.yaml: {key} must be a list of strings")
133
+ out: set[str] = set()
134
+ for i, item in enumerate(value):
135
+ if not isinstance(item, str) or not item.strip():
136
+ raise DataError(
137
+ f"invalid vault/ontology.yaml: {key}[{i}] must be a non-empty string"
138
+ )
139
+ out.add(item.strip())
140
+ return out
141
+
142
+
143
+ def _string_map(data: dict, key: str) -> dict[str, str]:
144
+ value = data.get(key, {})
145
+ if value is None:
146
+ return {}
147
+ if not isinstance(value, dict):
148
+ raise DataError(
149
+ f"invalid vault/ontology.yaml: {key} must be a mapping of strings to strings"
150
+ )
151
+ out: dict[str, str] = {}
152
+ for raw_k, raw_v in value.items():
153
+ if not isinstance(raw_k, str) or not raw_k.strip():
154
+ raise DataError(
155
+ f"invalid vault/ontology.yaml: {key} keys must be non-empty strings"
156
+ )
157
+ if not isinstance(raw_v, str) or not raw_v.strip():
158
+ raise DataError(
159
+ f"invalid vault/ontology.yaml: {key}.{raw_k} must be a non-empty string"
160
+ )
161
+ out[raw_k.strip()] = raw_v.strip()
162
+ return out
@@ -68,6 +68,17 @@ def _graph_lines(kb):
68
68
  return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
69
69
 
70
70
 
71
+ def _entities_lines(kb):
72
+ p = kb.root / "vault" / "facts" / "entities.ndjson"
73
+ if not p.exists():
74
+ return []
75
+ return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
76
+
77
+
78
+ def _write_ontology(kb, text):
79
+ (kb.root / "vault" / "ontology.yaml").write_text(text, encoding="utf-8")
80
+
81
+
71
82
  def _two_claims(kb):
72
83
  """Seed a contradiction pair to reconcile."""
73
84
  kb.add_raw("s", SRC)
@@ -244,6 +255,38 @@ def test_fact_add_json_reports_appended_records(kb, capsys):
244
255
  assert rec["anchor"].startswith("qh:")
245
256
 
246
257
 
258
+ def test_fact_add_ontology_canonicalizes_claim_predicate_aliases(kb):
259
+ kb.add_raw("s", SRC)
260
+ _write_ontology(
261
+ kb,
262
+ """
263
+ claim_predicates:
264
+ - caches
265
+ predicate_aliases:
266
+ p: caches
267
+ """,
268
+ )
269
+ rc = _run_add(
270
+ kb,
271
+ _ndjson(_claim("Caching answers beats recomputing them.", predicate="p")),
272
+ )
273
+ assert rc == 0
274
+ assert _claims_lines(kb)[0]["predicate"] == "caches"
275
+
276
+
277
+ def test_fact_add_ontology_rejects_unknown_claim_predicate(kb):
278
+ kb.add_raw("s", SRC)
279
+ _write_ontology(kb, "claim_predicates:\n - caches\n")
280
+
281
+ rc = _run_add(
282
+ kb,
283
+ _ndjson(_claim("Caching answers beats recomputing them.", predicate="p")),
284
+ )
285
+
286
+ assert rc == 3
287
+ assert _claims_lines(kb) == []
288
+
289
+
247
290
  # --------------------------------------------------------------------------- #
248
291
  # All-or-nothing batch + per-record failures (exit 1)
249
292
  # --------------------------------------------------------------------------- #
@@ -483,6 +526,93 @@ def test_fact_add_entities_appends_and_conflicts(kb, capsys):
483
526
  assert len(lines.splitlines()) == 1 # nothing else was appended
484
527
 
485
528
 
529
+ def test_fact_add_entities_accept_optional_uri_metadata(kb):
530
+ ent = {
531
+ "entity_id": "entity/duckdb",
532
+ "name": "DuckDB",
533
+ "kind": "tool",
534
+ "uri": "https://duckdb.org/",
535
+ "same_as": ["https://www.wikidata.org/wiki/Q118533698"],
536
+ "external_ids": {"wikidata": "Q118533698"},
537
+ }
538
+
539
+ assert _run_add(kb, _ndjson(ent), "--table", "entities") == 0
540
+
541
+ [stored] = _entities_lines(kb)
542
+ assert stored["uri"] == "https://duckdb.org/"
543
+ assert stored["same_as"] == ["https://www.wikidata.org/wiki/Q118533698"]
544
+ assert stored["external_ids"] == {"wikidata": "Q118533698"}
545
+
546
+
547
+ def test_fact_add_ontology_rejects_unknown_entity_kind_and_edge_kind(kb):
548
+ _write_ontology(
549
+ kb,
550
+ """
551
+ entity_kinds:
552
+ - tool
553
+ edge_kinds:
554
+ - depends-on
555
+ """,
556
+ )
557
+
558
+ assert (
559
+ _run_add(
560
+ kb,
561
+ _ndjson({"entity_id": "entity/a", "name": "A", "kind": "concept"}),
562
+ "--table",
563
+ "entities",
564
+ )
565
+ == 3
566
+ )
567
+ assert _run_add(kb, _ndjson(_edge(kind="relates-to")), "--table", "edges") == 3
568
+ assert _run_add(kb, _ndjson(_edge(kind="depends-on")), "--table", "edges") == 0
569
+
570
+
571
+ def test_ontology_command_reports_optional_vocabulary(kb, capsys):
572
+ _write_ontology(
573
+ kb,
574
+ """
575
+ entity_kinds:
576
+ - tool
577
+ edge_kinds:
578
+ - depends-on
579
+ claim_predicates:
580
+ - caches
581
+ predicate_aliases:
582
+ p: caches
583
+ """,
584
+ )
585
+
586
+ assert cli.main(["ontology", "--json", "--root", str(kb.root)]) == 0
587
+
588
+ data = json.loads(capsys.readouterr().out)
589
+ assert data["active"] is True
590
+ assert data["entity_kinds"] == ["tool"]
591
+ assert data["edge_kinds"] == ["depends-on"]
592
+ assert data["claim_predicates"] == ["caches"]
593
+ assert data["predicate_aliases"] == {"p": "caches"}
594
+
595
+
596
+ def test_ontology_rejects_alias_to_unknown_predicate(kb):
597
+ _write_ontology(
598
+ kb,
599
+ """
600
+ claim_predicates:
601
+ - caches
602
+ predicate_aliases:
603
+ p: stores
604
+ """,
605
+ )
606
+
607
+ assert cli.main(["ontology", "--root", str(kb.root)]) == 3
608
+
609
+
610
+ def test_ontology_rejects_unknown_top_level_key(kb):
611
+ _write_ontology(kb, "claim_predicate:\n - caches\n")
612
+
613
+ assert cli.main(["ontology", "--root", str(kb.root)]) == 3
614
+
615
+
486
616
  def test_fact_add_edges_appends_and_skips_duplicates(kb):
487
617
  edge = {"src": "entity/duckdb", "dst": "entity/motherduck", "kind": "made-by"}
488
618
  assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0
@@ -0,0 +1,102 @@
1
+ """`scrip init` — create the root skeleton (vault/ layers + .kb/ cache) so a new
2
+ instance starts detectable and green. Idempotent in an existing root; refuses to
3
+ nest a new instance inside an existing one."""
4
+
5
+ import json
6
+
7
+ from scrip import cli, manifest_path
8
+
9
+
10
+ def test_init_creates_skeleton_and_manifest(tmp_path, capsys):
11
+ root = tmp_path / "kb"
12
+ assert cli.main(["init", str(root)]) == 0
13
+ for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
14
+ assert (root / rel).is_dir()
15
+ # same effect as `scrip status --rebuild-manifest`: the cache exists
16
+ assert manifest_path(root).exists()
17
+ out = capsys.readouterr().out
18
+ assert f"initialized scriptorium root at {root}" in out
19
+ assert "scrip ingest" in out # next-steps hint: ingest -> compile -> verify
20
+ assert "scrip verify" in out
21
+
22
+
23
+ def test_init_defaults_to_cwd(tmp_path, monkeypatch):
24
+ monkeypatch.chdir(tmp_path)
25
+ assert cli.main(["init"]) == 0
26
+ assert (tmp_path / "vault" / "raw").is_dir()
27
+ assert (tmp_path / ".kb").is_dir()
28
+
29
+
30
+ def test_init_then_commands_resolve_root_without_flags(tmp_path, monkeypatch):
31
+ """The dogfooding gap this command closes: after init, `scrip status` and
32
+ `scrip verify` run green from inside the new root with no --root."""
33
+ root = tmp_path / "kb"
34
+ assert cli.main(["init", str(root)]) == 0
35
+ monkeypatch.chdir(root)
36
+ assert cli.main(["status"]) == 0
37
+ assert cli.main(["verify"]) == 0
38
+
39
+
40
+ def test_init_existing_root_is_noop(tmp_path, capsys):
41
+ root = tmp_path / "kb"
42
+ assert cli.main(["init", str(root)]) == 0
43
+ manifest_before = manifest_path(root).read_text(encoding="utf-8")
44
+ capsys.readouterr()
45
+ assert cli.main(["init", str(root)]) == 0
46
+ out = capsys.readouterr().out
47
+ assert f"already a scriptorium root: {root}" in out
48
+ # a true no-op: the manifest was not rewritten
49
+ assert manifest_path(root).read_text(encoding="utf-8") == manifest_before
50
+
51
+
52
+ def test_init_detects_spec_marker_root_as_existing(kb, capsys):
53
+ """A root marked by SPEC.md (no .kb/ needed) counts as already initialized."""
54
+ assert cli.main(["init", str(kb.root)]) == 0
55
+ assert "already a scriptorium root" in capsys.readouterr().out
56
+
57
+
58
+ def test_init_inside_existing_root_refused_exit_2(tmp_path):
59
+ root = tmp_path / "kb"
60
+ assert cli.main(["init", str(root)]) == 0
61
+ nested = root / "vault" / "raw" / "sub"
62
+ assert cli.main(["init", str(nested)]) == 2
63
+ # nothing was created for the refused nested instance
64
+ assert not nested.exists()
65
+
66
+
67
+ def test_init_target_is_a_file_exit_2(tmp_path):
68
+ f = tmp_path / "notes.md"
69
+ f.write_text("hi\n", encoding="utf-8")
70
+ assert cli.main(["init", str(f)]) == 2
71
+
72
+
73
+ def test_init_fills_in_missing_layers(tmp_path):
74
+ """A partial layout (vault/ alone is not a detectable root) is completed
75
+ rather than refused."""
76
+ root = tmp_path / "kb"
77
+ (root / "vault" / "raw").mkdir(parents=True)
78
+ assert cli.main(["init", str(root)]) == 0
79
+ for rel in ("vault/facts", "vault/wiki", ".kb"):
80
+ assert (root / rel).is_dir()
81
+
82
+
83
+ def test_init_layer_path_is_a_file_refused_exit_2(tmp_path):
84
+ """A file squatting a layer path is refused cleanly (exit 2), not an
85
+ uncaught FileExistsError mapped to the internal-error exit code."""
86
+ root = tmp_path / "kb"
87
+ (root / "vault").mkdir(parents=True)
88
+ (root / "vault" / "facts").write_text("oops\n", encoding="utf-8")
89
+ assert cli.main(["init", str(root)]) == 2
90
+
91
+
92
+ def test_init_json_shape(tmp_path, capsys):
93
+ root = tmp_path / "kb"
94
+ assert cli.main(["init", "--json", str(root)]) == 0
95
+ data = json.loads(capsys.readouterr().out)
96
+ assert set(data) == {"root", "status", "created"}
97
+ assert data["status"] == "initialized"
98
+ assert ".kb/manifest.json" in data["created"]
99
+ assert cli.main(["init", "--json", str(root)]) == 0
100
+ data = json.loads(capsys.readouterr().out)
101
+ assert data["status"] == "exists"
102
+ assert data["created"] == []
@@ -249,7 +249,7 @@ name = "exceptiongroup"
249
249
  version = "1.3.1"
250
250
  source = { registry = "https://pypi.org/simple" }
251
251
  dependencies = [
252
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
252
+ { name = "typing-extensions" },
253
253
  ]
254
254
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
255
255
  wheels = [
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.8.1"
1144
+ version = "0.10.0"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
File without changes