openkos 0.2.4__tar.gz → 0.2.6__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.
- {openkos-0.2.4 → openkos-0.2.6}/PKG-INFO +14 -6
- {openkos-0.2.4 → openkos-0.2.6}/README.md +12 -4
- {openkos-0.2.4 → openkos-0.2.6}/pyproject.toml +2 -4
- {openkos-0.2.4 → openkos-0.2.6}/pyproject.toml.orig +10 -4
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/decisions.py +30 -2
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/index.py +74 -4
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/ledger.py +22 -1
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/merge.py +49 -4
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/provenance.py +181 -5
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/curate.py +154 -8
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/main.py +1454 -140
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/next_action.py +108 -25
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/observability.py +37 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/config.py +334 -30
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/concept.py +1179 -57
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/judge.py +65 -5
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/lint.py +88 -5
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/base.py +19 -2
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/ollama.py +49 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/okf.py +90 -4
- openkos-0.2.6/src/openkos/resolution/reconciliation.py +139 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/similarity.py +101 -19
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/answer.py +37 -1
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/fusion.py +25 -1
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/findings.py +126 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/templates/agents.md.template +15 -2
- openkos-0.2.6/src/openkos/templates/openkos.yaml.template +60 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/vcs/git.py +28 -1
- openkos-0.2.4/src/openkos/templates/openkos.yaml.template +0 -33
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/bundle.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/links.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/listing.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/log.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/references.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/relations.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/bundle/source_titles.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/cli/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/extraction/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/fsio.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/analysis.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/base.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/proximity.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/sqlite_graph.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/graph/summary.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/lifecycle.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/llm/parsing.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/relations.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/model/types.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/py.typed +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/adjudication.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/candidates.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/contradiction.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/edge_typing.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/normalize.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/resolution/volatility_typing.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/retrieval/pool.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/sensitivity.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/source_title.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/__init__.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/derived.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/fts.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/reindex.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/state/vectorstore.py +0 -0
- {openkos-0.2.4 → openkos-0.2.6}/src/openkos/vcs/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openkos
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)
|
|
5
5
|
Keywords: okf,open-knowledge-format,knowledge-base,knowledge-graph,local-first,rag,llm,second-brain,cli
|
|
6
6
|
Author: Jason
|
|
@@ -21,7 +21,7 @@ Classifier: Typing :: Typed
|
|
|
21
21
|
Requires-Dist: networkx>=3.4
|
|
22
22
|
Requires-Dist: python-frontmatter>=1.3.0
|
|
23
23
|
Requires-Dist: pyyaml>=6.0.3
|
|
24
|
-
Requires-Dist:
|
|
24
|
+
Requires-Dist: rich>=13.8.0
|
|
25
25
|
Requires-Dist: sqlite-vec>=0.1.6
|
|
26
26
|
Requires-Dist: typer>=0.27.0
|
|
27
27
|
Requires-Python: >=3.12
|
|
@@ -59,15 +59,23 @@ ollama pull qwen3:8b # chat model — extraction and answers (~5 GB)
|
|
|
59
59
|
ollama pull bge-m3 # embedding model — semantic search (~1.2 GB)
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
>
|
|
62
|
+
> These two are all curation needs out of the box. For naming relations during curation there is an **optional measured upgrade** (`ollama pull gemma2:27b`, **15.6 GB**) that nearly doubles relation-type accuracy — opt in later with `models: {edge_typing: gemma2:27b}` in `openkos.yaml`; `openkos doctor` and `curate` both point at it.
|
|
63
63
|
|
|
64
64
|
**3 · Install the engine** (needs Python 3.12+ and [git](https://git-scm.com)):
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
uv tool install openkos # or: pipx install openkos — or: pip install openkos
|
|
68
|
+
openkos --version # check what you actually got
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
**Check that version.** If it does not match the badge at the top of this page, your package index is serving an older release. Install from the repository instead — same engine, current code:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv tool install --force git+https://github.com/jasonssdev/openkos
|
|
75
|
+
openkos --version
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That command is also how you track unreleased `main` at any time.
|
|
71
79
|
|
|
72
80
|
**4 · Check the setup before anything can fail:**
|
|
73
81
|
|
|
@@ -75,7 +83,7 @@ To track the latest unreleased `main` instead: `uv tool install git+https://gith
|
|
|
75
83
|
openkos doctor
|
|
76
84
|
```
|
|
77
85
|
|
|
78
|
-
|
|
86
|
+
Outside a workspace, expect one `[FAIL]` — *Workspace initialized*, since you haven't created one yet — and six `[SKIP]` lines for the checks that need a workspace to inspect. Everything else should `[PASS]`. Anything that fails prints the command that fixes it. `doctor` is also the first thing to run whenever something misbehaves later.
|
|
79
87
|
|
|
80
88
|
**5 · Create a knowledge base and run the loop:**
|
|
81
89
|
|
|
@@ -140,7 +148,7 @@ The wedge, in one line: **the local-first, personal producer-consumer-runtime fo
|
|
|
140
148
|
OpenKOS ships in three MVP arcs, each usable on its own. Full detail in [`docs/roadmap.md`](https://github.com/jasonssdev/openkos/blob/main/docs/roadmap.md).
|
|
141
149
|
|
|
142
150
|
- **MVP 1 — The Compiler. (Complete.)** The Karpathy loop, locally, over text: ingest → OKF concepts with provenance → cited query → freshness lint. Useful in an afternoon.
|
|
143
|
-
- **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
|
|
151
|
+
- **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), contradiction detection with durable verdicts (findings persist, so a repeat check costs no model calls, and `reconcile` records how you settled each one), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
|
|
144
152
|
- **MVP 3 — The Runtime and Interoperability.** An MCP server and APIs so agents use OpenKOS as durable memory; full OKF import/export with the wider ecosystem.
|
|
145
153
|
|
|
146
154
|
Beyond that: a desktop app, graph visualization, richer memory, and federation — explored only after the MVPs prove out with real users.
|
|
@@ -26,15 +26,23 @@ ollama pull qwen3:8b # chat model — extraction and answers (~5 GB)
|
|
|
26
26
|
ollama pull bge-m3 # embedding model — semantic search (~1.2 GB)
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
>
|
|
29
|
+
> These two are all curation needs out of the box. For naming relations during curation there is an **optional measured upgrade** (`ollama pull gemma2:27b`, **15.6 GB**) that nearly doubles relation-type accuracy — opt in later with `models: {edge_typing: gemma2:27b}` in `openkos.yaml`; `openkos doctor` and `curate` both point at it.
|
|
30
30
|
|
|
31
31
|
**3 · Install the engine** (needs Python 3.12+ and [git](https://git-scm.com)):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
uv tool install openkos # or: pipx install openkos — or: pip install openkos
|
|
35
|
+
openkos --version # check what you actually got
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
**Check that version.** If it does not match the badge at the top of this page, your package index is serving an older release. Install from the repository instead — same engine, current code:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install --force git+https://github.com/jasonssdev/openkos
|
|
42
|
+
openkos --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That command is also how you track unreleased `main` at any time.
|
|
38
46
|
|
|
39
47
|
**4 · Check the setup before anything can fail:**
|
|
40
48
|
|
|
@@ -42,7 +50,7 @@ To track the latest unreleased `main` instead: `uv tool install git+https://gith
|
|
|
42
50
|
openkos doctor
|
|
43
51
|
```
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
Outside a workspace, expect one `[FAIL]` — *Workspace initialized*, since you haven't created one yet — and six `[SKIP]` lines for the checks that need a workspace to inspect. Everything else should `[PASS]`. Anything that fails prints the command that fixes it. `doctor` is also the first thing to run whenever something misbehaves later.
|
|
46
54
|
|
|
47
55
|
**5 · Create a knowledge base and run the loop:**
|
|
48
56
|
|
|
@@ -107,7 +115,7 @@ The wedge, in one line: **the local-first, personal producer-consumer-runtime fo
|
|
|
107
115
|
OpenKOS ships in three MVP arcs, each usable on its own. Full detail in [`docs/roadmap.md`](https://github.com/jasonssdev/openkos/blob/main/docs/roadmap.md).
|
|
108
116
|
|
|
109
117
|
- **MVP 1 — The Compiler. (Complete.)** The Karpathy loop, locally, over text: ingest → OKF concepts with provenance → cited query → freshness lint. Useful in an afternoon.
|
|
110
|
-
- **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
|
|
118
|
+
- **MVP 2 — The Graph and Memory. (Complete.)** Entity/relationship extraction and reversible merge, a typed knowledge graph (an OpenKOS layer over OKF's untyped links — other tools still read the bundle fine), hybrid retrieval (lexical and semantic, rank-fused), contradiction detection with durable verdicts (findings persist, so a repeat check costs no model calls, and `reconcile` records how you settled each one), a fail-closed sensitivity filter (confidential concepts never leave the machine — held back from any backend that is not verifiably local), a guided curation loop, reference-aware `forget` plus an irreversible `purge` (right-to-be-forgotten), and answers that file back into the base (the two-output rule).
|
|
111
119
|
- **MVP 3 — The Runtime and Interoperability.** An MCP server and APIs so agents use OpenKOS as durable memory; full OKF import/export with the wider ecosystem.
|
|
112
120
|
|
|
113
121
|
Beyond that: a desktop app, graph visualization, richer memory, and federation — explored only after the MVPs prove out with real users.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openkos"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.6"
|
|
4
4
|
description = "Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -34,7 +34,7 @@ dependencies = [
|
|
|
34
34
|
"networkx>=3.4",
|
|
35
35
|
"python-frontmatter>=1.3.0",
|
|
36
36
|
"pyyaml>=6.0.3",
|
|
37
|
-
"
|
|
37
|
+
"rich>=13.8.0",
|
|
38
38
|
"sqlite-vec>=0.1.6",
|
|
39
39
|
"typer>=0.27.0",
|
|
40
40
|
]
|
|
@@ -61,10 +61,8 @@ dev = [
|
|
|
61
61
|
"git-filter-repo>=2.47.0",
|
|
62
62
|
"mypy>=2.3.0",
|
|
63
63
|
"pre-commit>=4.6.0",
|
|
64
|
-
"pydantic>=2.13.4",
|
|
65
64
|
"pytest>=9.1.1",
|
|
66
65
|
"pytest-cov>=7.1.0",
|
|
67
|
-
"ruamel-yaml>=0.19.1",
|
|
68
66
|
"ruff>=0.15.21",
|
|
69
67
|
"types-networkx>=3.6.1.20260624",
|
|
70
68
|
"types-pyyaml>=6.0.12.20260518",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openkos"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.6"
|
|
4
4
|
description = "Local-first engine that turns your scattered text into a living, portable knowledge base in the Open Knowledge Format (OKF)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -40,7 +40,12 @@ dependencies = [
|
|
|
40
40
|
"networkx>=3.4",
|
|
41
41
|
"python-frontmatter>=1.3.0",
|
|
42
42
|
"pyyaml>=6.0.3",
|
|
43
|
-
|
|
43
|
+
# `cli/main.py` imports `rich.console.Console` directly. Typer happens to
|
|
44
|
+
# depend on rich too, but an undeclared transitive import is a dependency
|
|
45
|
+
# we do not control: the day Typer drops or vendors it, openkos breaks at
|
|
46
|
+
# import time on an install that resolved cleanly. Declare what we import.
|
|
47
|
+
# The floor matches Typer's own so the resolver has no extra constraint.
|
|
48
|
+
"rich>=13.8.0",
|
|
44
49
|
"sqlite-vec>=0.1.6",
|
|
45
50
|
"typer>=0.27.0",
|
|
46
51
|
]
|
|
@@ -59,14 +64,15 @@ requires = ["uv_build>=0.11.29,<0.12.0"]
|
|
|
59
64
|
build-backend = "uv_build"
|
|
60
65
|
|
|
61
66
|
[dependency-groups]
|
|
67
|
+
# `git-filter-repo` is a real test requirement, not just `purge`'s runtime
|
|
68
|
+
# system tool: `test_filter_repo_available_true_when_installed` asserts the
|
|
69
|
+
# PATH probe finds it, so it fails on a machine without the binary.
|
|
62
70
|
dev = [
|
|
63
71
|
"git-filter-repo>=2.47.0",
|
|
64
72
|
"mypy>=2.3.0",
|
|
65
73
|
"pre-commit>=4.6.0",
|
|
66
|
-
"pydantic>=2.13.4",
|
|
67
74
|
"pytest>=9.1.1",
|
|
68
75
|
"pytest-cov>=7.1.0",
|
|
69
|
-
"ruamel-yaml>=0.19.1",
|
|
70
76
|
"ruff>=0.15.21",
|
|
71
77
|
"types-networkx>=3.6.1.20260624",
|
|
72
78
|
"types-pyyaml>=6.0.12.20260518",
|
|
@@ -151,7 +151,16 @@ def read_decisions(concept_id: str, bundle_dir: Path) -> list[DecisionRecord]:
|
|
|
151
151
|
sidecar on disk (no decision ever declined/reopened under this concept
|
|
152
152
|
id) returns `[]` -- mirrors `bundle.ledger.read_entries`'s own
|
|
153
153
|
"absent file" contract."""
|
|
154
|
-
|
|
154
|
+
return read_decisions_at(decisions_path_for(concept_id, bundle_dir))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def read_decisions_at(path: Path) -> list[DecisionRecord]:
|
|
158
|
+
"""Read every `DecisionRecord` from the sidecar AT `path` -- the walked-
|
|
159
|
+
path reader the privacy sweep and the declined-listing views use, so a
|
|
160
|
+
record is read from the file actually on disk, never from a path rebuilt
|
|
161
|
+
from a (possibly drifted or hostile) `concept_id` frontmatter field.
|
|
162
|
+
`read_decisions` is the id-addressed wrapper for callers that legitimately
|
|
163
|
+
own the id. Absent file returns `[]`."""
|
|
155
164
|
if not path.is_file():
|
|
156
165
|
return []
|
|
157
166
|
metadata, _ = okf.load_frontmatter(path.read_text(encoding="utf-8"))
|
|
@@ -172,7 +181,26 @@ def write_decisions(
|
|
|
172
181
|
|
|
173
182
|
Written via `fsio.write_atomic`, over `okf.dump_frontmatter`'s output
|
|
174
183
|
with an empty body (ADR-0002 invariant 3, preserved literally)."""
|
|
175
|
-
|
|
184
|
+
return rewrite_decisions_at(
|
|
185
|
+
decisions_path_for(concept_id, bundle_dir),
|
|
186
|
+
concept_id=concept_id,
|
|
187
|
+
records=records,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def rewrite_decisions_at(
|
|
192
|
+
path: Path, *, concept_id: str, records: list[DecisionRecord]
|
|
193
|
+
) -> Path:
|
|
194
|
+
"""(Re)write the sidecar AT `path` to hold EXACTLY `records`, using
|
|
195
|
+
`concept_id` only as container CONTENT -- never to derive the path.
|
|
196
|
+
|
|
197
|
+
The walked-path twin of `bundle.ledger.rewrite_entries_at`: the privacy
|
|
198
|
+
sweep must write each rewrite back to the path it WALKED, not to a path
|
|
199
|
+
rebuilt from the (possibly drifted or hostile) `concept_id` frontmatter
|
|
200
|
+
field, which would let a traversal id escape the bundle and silently
|
|
201
|
+
scrub the wrong file. `write_decisions` is the id-addressed wrapper. An
|
|
202
|
+
empty `records` list removes the file; removing an absent file is a
|
|
203
|
+
no-op."""
|
|
176
204
|
if not records:
|
|
177
205
|
if path.is_file():
|
|
178
206
|
path.unlink()
|
|
@@ -52,6 +52,70 @@ def _reject_newline(field: str, value: str) -> None:
|
|
|
52
52
|
raise ValueError(f"index.md: {field!r} must not contain a newline")
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
_TITLE_LABEL_DELIMITERS_RE = re.compile(r"[\[\]]")
|
|
56
|
+
_SLUG_TARGET_DELIMITERS_RE = re.compile(r"[()]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def reject_title_link_delimiters(field: str, value: str) -> None:
|
|
60
|
+
r"""Raise `ValueError` if `value` contains `[` or `]` (RISK-1, insert path).
|
|
61
|
+
|
|
62
|
+
A `title` is interpolated verbatim into the LABEL span of the bullet
|
|
63
|
+
(`* [{title}](...)`). A `[`/`]` there forges a second link or closes the
|
|
64
|
+
label early, so `_LINK_RE`/`_LABELLED_LINK_RE` then match the FORGED link
|
|
65
|
+
instead of the real one -- the entry becomes invisible to
|
|
66
|
+
`remove_index_entry`/`relabel_index_entry`, and a forged id can delete a
|
|
67
|
+
DIFFERENT entry. This is the untrusted, LLM-derived counterpart of
|
|
68
|
+
`_reject_markdown_link_delimiters` (which guards the operator-supplied
|
|
69
|
+
relabel path): the STRICT insert-time backstop that fails closed for any
|
|
70
|
+
caller. Callers that want to PRESERVE a title carrying brackets (a benign
|
|
71
|
+
filename like `notes[1].txt`, as well as a hostile one) sanitize it first
|
|
72
|
+
with `sanitize_link_label`, which neutralizes the delimiters instead of
|
|
73
|
+
rejecting the whole value.
|
|
74
|
+
|
|
75
|
+
Parentheses and backticks are deliberately NOT rejected here: inside the
|
|
76
|
+
label they sit within `[^\]]*` and never reach the target, so a common
|
|
77
|
+
title like ``map() function`` or ``` `useState` `` stays valid --
|
|
78
|
+
rejecting them would be a false positive with no security benefit.
|
|
79
|
+
"""
|
|
80
|
+
if _TITLE_LABEL_DELIMITERS_RE.search(value):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"index.md: {field!r} must not contain a markdown link label "
|
|
83
|
+
"delimiter ('[' or ']')"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reject_slug_link_delimiters(field: str, value: str) -> None:
|
|
88
|
+
"""Raise `ValueError` if `value` contains `(` or `)` (RISK-1, insert path).
|
|
89
|
+
|
|
90
|
+
A `slug` is interpolated into the link TARGET span
|
|
91
|
+
(`](/{link_dir}/{slug}.md)`). A `(`/`)` there closes or forges the
|
|
92
|
+
target, changing the bullet's resolved identity. Slugs produced by
|
|
93
|
+
`_slugify` can never contain these, so this is defense-in-depth for the
|
|
94
|
+
generic public inserter.
|
|
95
|
+
"""
|
|
96
|
+
if _SLUG_TARGET_DELIMITERS_RE.search(value):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"index.md: {field!r} must not contain a markdown link target "
|
|
99
|
+
"delimiter ('(' or ')')"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sanitize_link_label(title: str) -> str:
|
|
104
|
+
r"""Return `title` with markdown link LABEL delimiters (`[`, `]`) replaced
|
|
105
|
+
by their round-paren equivalents, so the value is safe to interpolate
|
|
106
|
+
into a bullet's `[label](...)` span in `index.md`/`log.md`.
|
|
107
|
+
|
|
108
|
+
Brackets close or forge the label for the regex-based `_LINK_RE`/
|
|
109
|
+
`_LABELLED_LINK_RE` matchers -- maliciously, via an injected LLM-extracted
|
|
110
|
+
title, or benignly, via a filename like `notes[1].txt`. Parentheses sit
|
|
111
|
+
inside `[^\]]*` and are inert there, so the substitution keeps the entry
|
|
112
|
+
matchable/removable by its id while preserving the visual grouping. This
|
|
113
|
+
is DISPLAY text only: identity is the slug, which `_slugify` derives
|
|
114
|
+
independently, so the substitution never changes which object exists.
|
|
115
|
+
"""
|
|
116
|
+
return title.replace("[", "(").replace("]", ")")
|
|
117
|
+
|
|
118
|
+
|
|
55
119
|
# `_CANONICAL_SECTION_ORDER` is now derived from
|
|
56
120
|
# `openkos.model.types.REGISTRY` -- see that module for the single source of
|
|
57
121
|
# truth.
|
|
@@ -83,10 +147,14 @@ def insert_index_entry(
|
|
|
83
147
|
eight currently exist -- preserving the historical Sources-last behavior
|
|
84
148
|
byte-identically.
|
|
85
149
|
`title`/`slug`/`description` are each rejected (`ValueError`) if they
|
|
86
|
-
contain a newline (RISK-1) -- see `_reject_newline`.
|
|
87
|
-
|
|
88
|
-
`
|
|
89
|
-
|
|
150
|
+
contain a newline (RISK-1) -- see `_reject_newline`. `title` is
|
|
151
|
+
additionally rejected if it carries a markdown link LABEL delimiter
|
|
152
|
+
(`[`/`]`) and `slug` if it carries a link TARGET delimiter (`(`/`)`) --
|
|
153
|
+
see `reject_title_link_delimiters`/`_reject_slug_link_delimiters` -- so an
|
|
154
|
+
untrusted, LLM-derived title can never forge or break the bullet's first
|
|
155
|
+
link. This guard applies to every section, including untrusted object
|
|
156
|
+
fields. `section` MUST be one of the canonical sections, else
|
|
157
|
+
`ValueError` -- there is no defined rank for an unknown section.
|
|
90
158
|
"""
|
|
91
159
|
if section not in _CANONICAL_SECTION_ORDER:
|
|
92
160
|
raise ValueError(
|
|
@@ -95,6 +163,8 @@ def insert_index_entry(
|
|
|
95
163
|
_reject_newline("title", title)
|
|
96
164
|
_reject_newline("slug", slug)
|
|
97
165
|
_reject_newline("description", description)
|
|
166
|
+
reject_title_link_delimiters("title", title)
|
|
167
|
+
_reject_slug_link_delimiters("slug", slug)
|
|
98
168
|
frontmatter_block, body = _split_frontmatter_verbatim(index_text)
|
|
99
169
|
chunks = _SECTION_SPLIT_RE.split(body)
|
|
100
170
|
preamble, section_chunks = chunks[0], chunks[1:]
|
|
@@ -212,7 +212,28 @@ def write_entries(
|
|
|
212
212
|
`entries` list removes the sidecar file entirely (a survivor with no
|
|
213
213
|
remaining merges keeps no empty ledger on disk); removing an
|
|
214
214
|
already-absent sidecar is a no-op, not an error."""
|
|
215
|
-
|
|
215
|
+
return rewrite_entries_at(
|
|
216
|
+
ledger_path_for(concept_id, bundle_dir),
|
|
217
|
+
survivor_id=survivor_id,
|
|
218
|
+
entries=entries,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def rewrite_entries_at(
|
|
223
|
+
path: Path, *, survivor_id: str, entries: list[okf.MergeLedgerEntry]
|
|
224
|
+
) -> Path:
|
|
225
|
+
"""(Re)write the sidecar AT `path` to hold EXACTLY `entries`, using
|
|
226
|
+
`survivor_id` only as container CONTENT -- never to derive the path.
|
|
227
|
+
|
|
228
|
+
The privacy sweep walks real sidecar paths and MUST write each rewrite
|
|
229
|
+
back to the path it WALKED, not to a path rebuilt from the (possibly
|
|
230
|
+
drifted or hostile) `survivor_id` frontmatter field. Rebuilding it both
|
|
231
|
+
lets a traversal id escape the bundle (arbitrary-file create/delete with
|
|
232
|
+
the `.ledger.okf` suffix) AND silently writes a legit NFC/NFD-drifted
|
|
233
|
+
sidecar to the wrong place while reporting the walked one as cleaned --
|
|
234
|
+
for a privacy sweep, a silent scrub miss. `write_entries` is the
|
|
235
|
+
id-addressed wrapper for callers that legitimately own the id. An empty
|
|
236
|
+
`entries` list removes the file; removing an absent file is a no-op."""
|
|
216
237
|
if not entries:
|
|
217
238
|
if path.is_file():
|
|
218
239
|
path.unlink()
|
|
@@ -72,6 +72,21 @@ def _reject_same_or_blank(survivor_id: str, absorbed_id: str) -> None:
|
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _absorbed_snapshot_has_content(absorbed_snapshot: str) -> bool:
|
|
76
|
+
"""`True` unless `absorbed_snapshot`'s BODY is provably empty or
|
|
77
|
+
whitespace-only (#685): only a body with actual content can have been
|
|
78
|
+
woven undelimited into a reconciled survivor, so only it earns the
|
|
79
|
+
carried-content annotation. Fails CLOSED to `True` (annotate) on a
|
|
80
|
+
snapshot this planner cannot parse -- under-redacting a body that might
|
|
81
|
+
exist is the failure mode the annotation exists to prevent (#602's
|
|
82
|
+
privacy-over-reversibility rule)."""
|
|
83
|
+
try:
|
|
84
|
+
_, body = okf.load_frontmatter(absorbed_snapshot)
|
|
85
|
+
except Exception: # broad: any parse failure means "cannot prove empty"
|
|
86
|
+
return True
|
|
87
|
+
return body.strip() != ""
|
|
88
|
+
|
|
89
|
+
|
|
75
90
|
def _reject_already_merged(
|
|
76
91
|
absorbed_id: str, existing_entries: list[okf.MergeLedgerEntry]
|
|
77
92
|
) -> None:
|
|
@@ -115,9 +130,10 @@ def plan_merge(
|
|
|
115
130
|
rewrite-provenance-on-merge) likewise defaults to `[]`; the actual
|
|
116
131
|
third-party inbound scan (`bundle/provenance.py`) and CLI wiring are
|
|
117
132
|
PR2's concern -- this layer only carries whatever the caller injects
|
|
118
|
-
into the new ledger entry, and ALWAYS writes `
|
|
119
|
-
(
|
|
120
|
-
|
|
133
|
+
into the new ledger entry, and ALWAYS writes `MERGE_LEDGER_SCHEMA_V4`
|
|
134
|
+
(#667: v4 adds `carried_content_ids`, computed HERE from
|
|
135
|
+
`existing_entries` vs `survivor_text`; the reader still accepts v1-v3
|
|
136
|
+
entries already on disk from before this merge).
|
|
121
137
|
|
|
122
138
|
`existing_entries` (durable-derived-state slice 1a) is the survivor's
|
|
123
139
|
CURRENT sidecar content, read by the caller via `bundle.ledger.
|
|
@@ -148,9 +164,25 @@ def plan_merge(
|
|
|
148
164
|
survivor_id,
|
|
149
165
|
)
|
|
150
166
|
|
|
167
|
+
# #667: a prior absorbed id whose delimited section is ABSENT from the
|
|
168
|
+
# survivor text being snapshotted has been woven in undelimited (a #645
|
|
169
|
+
# reconciliation, or an operator edit -- either way the #602 structural
|
|
170
|
+
# excision cannot reach it inside THIS entry's `survivor_before`).
|
|
171
|
+
# Recorded at snapshot time, when the fact is still decidable.
|
|
172
|
+
# #685: a prior whose absorbed BODY was empty/whitespace-only
|
|
173
|
+
# contributed no content, so its missing delimiter must not annotate --
|
|
174
|
+
# the annotation would buy no privacy and cost a wholesale
|
|
175
|
+
# `survivor_before` redaction on a later forget.
|
|
176
|
+
carried_content_ids = [
|
|
177
|
+
prior.absorbed_id
|
|
178
|
+
for prior in existing
|
|
179
|
+
if okf.merged_content_heading(prior.absorbed_id) not in survivor_text
|
|
180
|
+
and _absorbed_snapshot_has_content(prior.absorbed_snapshot)
|
|
181
|
+
]
|
|
182
|
+
|
|
151
183
|
sensitivity_before = survivor_metadata.get("sensitivity")
|
|
152
184
|
entry = okf.MergeLedgerEntry(
|
|
153
|
-
schema=okf.
|
|
185
|
+
schema=okf.MERGE_LEDGER_SCHEMA_V4,
|
|
154
186
|
merged_at=merged_at,
|
|
155
187
|
absorbed_id=absorbed_id,
|
|
156
188
|
absorbed_snapshot=absorbed_text,
|
|
@@ -168,6 +200,7 @@ def plan_merge(
|
|
|
168
200
|
provenance_rewrites=list(provenance_rewrites)
|
|
169
201
|
if provenance_rewrites is not None
|
|
170
202
|
else [],
|
|
203
|
+
carried_content_ids=carried_content_ids,
|
|
171
204
|
)
|
|
172
205
|
|
|
173
206
|
merged_survivor = okf.dump_frontmatter(merged_metadata, merged_body)
|
|
@@ -242,6 +275,18 @@ def plan_unmerge(
|
|
|
242
275
|
f"one command, or unmerge each pair in that order"
|
|
243
276
|
)
|
|
244
277
|
|
|
278
|
+
# #667: forget's sweep replaces a carried-content snapshot with the
|
|
279
|
+
# redaction sentinel (privacy over reversibility). Restoring it would
|
|
280
|
+
# overwrite the LIVE survivor body with the notice string -- refuse
|
|
281
|
+
# loudly instead; this merge is no longer reversible by design.
|
|
282
|
+
if tail.survivor_before == okf.REDACTED_SNAPSHOT_SENTINEL:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"{survivor_id!r}'s ledger entry for {absorbed_id!r} was redacted "
|
|
285
|
+
"by `openkos forget` (it carried a forgotten concept's "
|
|
286
|
+
"reconciled content); this merge is no longer reversible -- "
|
|
287
|
+
"unmerge refused"
|
|
288
|
+
)
|
|
289
|
+
|
|
245
290
|
return UnmergePlan(
|
|
246
291
|
restored_survivor=tail.survivor_before,
|
|
247
292
|
restored_absorbed=tail.absorbed_snapshot,
|
|
@@ -370,6 +370,170 @@ def _source_levels(files: Mapping[str, str]) -> dict[str, str]:
|
|
|
370
370
|
return source_levels
|
|
371
371
|
|
|
372
372
|
|
|
373
|
+
def _levels_by_id(files: Mapping[str, str]) -> dict[str, object]:
|
|
374
|
+
"""Parse every file once into `id -> raw sensitivity value`, keeping the
|
|
375
|
+
value RAW (`object`, not `str`).
|
|
376
|
+
|
|
377
|
+
Ranking is unaffected by the choice: `okf.combine_sensitivity` ranks
|
|
378
|
+
through the private fail-closed `okf._rank` (ADR-0003), where a missing
|
|
379
|
+
value floors at `private` and a dirty non-string floors at `confidential`
|
|
380
|
+
-- and an unrecognized STRING floors at `confidential` too, so `42` and
|
|
381
|
+
`"42"` rank identically. This docstring used to claim the opposite (that
|
|
382
|
+
coercing to `str`, as `_source_levels` must to satisfy
|
|
383
|
+
`resolve_source_raises`'s `level: str` signature, "would turn a dirty
|
|
384
|
+
`int` into a string that no longer fails closed the same way"); measured
|
|
385
|
+
against every value YAML frontmatter can produce -- `int`, `bool`, `list`,
|
|
386
|
+
`dict`, `float`, `date`, blank and unrecognized strings -- that claim is
|
|
387
|
+
FALSE, and `_source_levels`'s own docstring already said so correctly
|
|
388
|
+
(issue #736). Nothing in this module's behavior rested on it.
|
|
389
|
+
|
|
390
|
+
WHAT RAW IS ACTUALLY LOAD-BEARING FOR is reporting, not ranking:
|
|
391
|
+
`resolve_cited_high_water_raises` hands this value straight to
|
|
392
|
+
`okf.DescendantRaise.current`, which a preview shows the operator as what
|
|
393
|
+
the file HOLDS. Coercing would print `'42'` where the frontmatter holds
|
|
394
|
+
`42` -- a preview claiming a string the operator never wrote. Of everything
|
|
395
|
+
stated here, that reporting property is the ONLY one a `str` coercion
|
|
396
|
+
breaks, and it is what
|
|
397
|
+
`test_a_present_but_dirty_sensitivity_fails_closed_and_propagates` pins.
|
|
398
|
+
|
|
399
|
+
A file whose frontmatter fails to parse is SKIPPED, mirroring
|
|
400
|
+
`_parse_provenance_by_id`.
|
|
401
|
+
"""
|
|
402
|
+
levels: dict[str, object] = {}
|
|
403
|
+
for path, text in files.items():
|
|
404
|
+
metadata: dict[str, object] | None
|
|
405
|
+
try:
|
|
406
|
+
metadata, _ = okf.load_frontmatter(text)
|
|
407
|
+
except Exception: # broad: malformed frontmatter is skipped rather
|
|
408
|
+
# than surfaced, mirroring `_parse_provenance_by_id`
|
|
409
|
+
metadata = None
|
|
410
|
+
if metadata is None:
|
|
411
|
+
continue
|
|
412
|
+
levels[_normalize_id(path)] = metadata.get("sensitivity")
|
|
413
|
+
return levels
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def resolve_cited_high_water_raises(
|
|
417
|
+
files: Mapping[str, str],
|
|
418
|
+
) -> list[okf.DescendantRaise]:
|
|
419
|
+
"""Pure fixpoint maintaining ADR-0003's high-water mark for a concept
|
|
420
|
+
whose provenance spans MORE THAN ONE Source (issue #697).
|
|
421
|
+
|
|
422
|
+
ADR-0012 deferred exactly this case, and named the deferral: a concept in
|
|
423
|
+
no single Source's closure "stays reported, not resolved, until a human
|
|
424
|
+
acts". `resolve_source_raises` cannot reach it because
|
|
425
|
+
`provenance_closure`'s conservative NON-EMPTY SUBSET rule keeps out any
|
|
426
|
+
concept still citing a Source outside the root's closure -- correct for
|
|
427
|
+
propagating ONE Source's level down its exclusive descendants, and exactly
|
|
428
|
+
wrong for a high-water mark, where citing a confidential source and a
|
|
429
|
+
public one means confidential. The subset rule under-classifies here.
|
|
430
|
+
|
|
431
|
+
THE RULE MIRRORS BIRTH, not the closure walk. `cli/main.py`'s
|
|
432
|
+
`_stage_filed_answer` folds `okf.combine_sensitivity` over each CITED
|
|
433
|
+
concept's own level to compute a filing's sensitivity; this recomputes the
|
|
434
|
+
same fold over the same direct `provenance` entries, so an object's
|
|
435
|
+
maintained level is the one its own birth rule would produce today. Reading
|
|
436
|
+
Source ANCESTORS instead (`provenance_source_ancestors`, #628) would miss a
|
|
437
|
+
cited intermediate concept raised by hand without its Source.
|
|
438
|
+
|
|
439
|
+
WHY A FIXPOINT rather than one pass: raising `mid` changes the input for
|
|
440
|
+
everything citing `mid`. A single pass would repair one level and leave the
|
|
441
|
+
same gap one level up. Termination is the same argument
|
|
442
|
+
`provenance_closure` makes -- `combine_sensitivity` is monotone and
|
|
443
|
+
`okf.SENSITIVITY_ORDER` is finite, so levels only ever rise and the loop
|
|
444
|
+
halts, including on a provenance cycle (two concepts citing each other
|
|
445
|
+
converge on their shared maximum) and on self-referential provenance.
|
|
446
|
+
|
|
447
|
+
THE RE-SCAN IS NOT WHERE THE TIME GOES, so do not "fix" it first. The walk
|
|
448
|
+
order is arbitrary, and on a linear chain inserted in reverse the loop
|
|
449
|
+
needs one pass per link -- genuinely quadratic. Measured once, on synthetic
|
|
450
|
+
bundles, 2026-08-16 (issue #736); the ratios are what to trust, not the
|
|
451
|
+
millisecond figures, which are one machine on one day. A real bundle shape,
|
|
452
|
+
where every insight cites Sources DIRECTLY as `query --save` files them,
|
|
453
|
+
converged in TWO passes and spent 2.3% of the call in this loop at 1k docs
|
|
454
|
+
and at 10k alike; the other ~98% was the three whole-bundle frontmatter
|
|
455
|
+
parses above. Even the adversarial 400-deep reverse chain -- a shape
|
|
456
|
+
nothing in this product builds -- cost 36ms.
|
|
457
|
+
Reordering the walk topologically was therefore REJECTED: it optimizes 2%
|
|
458
|
+
of a maintenance verb an operator runs explicitly. If this call ever does
|
|
459
|
+
need to be faster, collapse the three parses into one.
|
|
460
|
+
|
|
461
|
+
A concept is staged only when EVERY cited id resolves to a file in `files`.
|
|
462
|
+
A dangling citation leaves it unstaged, matching `lint`'s own
|
|
463
|
+
`multi-source-uncovered` rule and ADR-0012 design D8: that signal belongs
|
|
464
|
+
to `check_dangling_provenance`. The alternative -- folding an unresolvable
|
|
465
|
+
citation to `confidential`, as `_stage_filed_answer` does at birth -- is
|
|
466
|
+
deliberately NOT copied here. At birth it guards ONE document the operator
|
|
467
|
+
is creating; in a bundle-wide sweep it would raise every descendant of one
|
|
468
|
+
dangling ref, a blast radius no preview makes safe, and it would contradict
|
|
469
|
+
the `lint` finding that sent the operator to this verb.
|
|
470
|
+
|
|
471
|
+
A `type: Source` is NEVER staged: a Source's level is operator-set truth,
|
|
472
|
+
never derived from what it cites. In practice a Source cites its raw
|
|
473
|
+
`resource`, which never resolves to a bundle id and would leave it unstaged
|
|
474
|
+
anyway; the explicit type guard states the invariant rather than resting on
|
|
475
|
+
that coincidence.
|
|
476
|
+
|
|
477
|
+
Raise-only by construction (`combine_sensitivity` never lowers), so a
|
|
478
|
+
concept deliberately classified ABOVE everything it cites keeps its level.
|
|
479
|
+
The returned list is `sorted()` by `concept_id`, each entry's `content` a
|
|
480
|
+
full `okf.dump_frontmatter` re-render of the ORIGINAL metadata with only
|
|
481
|
+
`sensitivity` replaced.
|
|
482
|
+
"""
|
|
483
|
+
provenance_by_id = _parse_provenance_by_id(files)
|
|
484
|
+
levels = _levels_by_id(files)
|
|
485
|
+
source_ids = set(_source_levels(files))
|
|
486
|
+
|
|
487
|
+
# Only concepts whose every citation resolves are eligible; the ineligible
|
|
488
|
+
# are excluded once, before the loop, so the fixpoint cannot admit one
|
|
489
|
+
# through a later iteration.
|
|
490
|
+
eligible = {
|
|
491
|
+
concept_id: parents
|
|
492
|
+
for concept_id, parents in provenance_by_id.items()
|
|
493
|
+
if parents
|
|
494
|
+
and concept_id not in source_ids
|
|
495
|
+
and all(parent in levels for parent in parents)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
resolved = dict(levels)
|
|
499
|
+
changed = True
|
|
500
|
+
while changed:
|
|
501
|
+
changed = False
|
|
502
|
+
for concept_id, parents in eligible.items():
|
|
503
|
+
current = resolved[concept_id]
|
|
504
|
+
combined = current
|
|
505
|
+
for parent in parents:
|
|
506
|
+
combined = okf.combine_sensitivity(combined, resolved[parent])
|
|
507
|
+
if combined != current:
|
|
508
|
+
resolved[concept_id] = combined
|
|
509
|
+
changed = True
|
|
510
|
+
|
|
511
|
+
raises: list[okf.DescendantRaise] = []
|
|
512
|
+
for concept_id in sorted(eligible):
|
|
513
|
+
current = levels[concept_id]
|
|
514
|
+
# Re-combined rather than read straight out of `resolved`, for a typed
|
|
515
|
+
# `str`: the fold seeds from RAW frontmatter values (`object`), which
|
|
516
|
+
# is what keeps `okf._rank`'s fail-closed ranking intact. The fold only
|
|
517
|
+
# ever raises, so this is the canonical form of `resolved[concept_id]`.
|
|
518
|
+
# The comparison stays against the RAW `current`, matching
|
|
519
|
+
# `resolve_source_raises`: a document with no `sensitivity` key at all
|
|
520
|
+
# differs from any canonical level and is written explicitly.
|
|
521
|
+
new_level = okf.combine_sensitivity(current, resolved[concept_id])
|
|
522
|
+
if new_level == current:
|
|
523
|
+
continue
|
|
524
|
+
metadata, body = okf.load_frontmatter(files[f"{concept_id}.md"])
|
|
525
|
+
metadata["sensitivity"] = new_level
|
|
526
|
+
raises.append(
|
|
527
|
+
okf.DescendantRaise(
|
|
528
|
+
concept_id=concept_id,
|
|
529
|
+
current=current,
|
|
530
|
+
new_level=new_level,
|
|
531
|
+
content=okf.dump_frontmatter(metadata, body),
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
return raises
|
|
535
|
+
|
|
536
|
+
|
|
373
537
|
def resolve_backfill_raises(files: Mapping[str, str]) -> list[okf.DescendantRaise]:
|
|
374
538
|
"""Pure, bundle-wide sweep core `backfill-sensitivity` needs (design
|
|
375
539
|
D4/D5/D6) -- everything the verb requires that is NOT the Typer command:
|
|
@@ -418,15 +582,27 @@ def resolve_backfill_raises(files: Mapping[str, str]) -> list[okf.DescendantRais
|
|
|
418
582
|
|
|
419
583
|
best: dict[str, okf.DescendantRaise] = {}
|
|
420
584
|
best_rank: dict[str, int] = {}
|
|
585
|
+
|
|
586
|
+
def _offer(descendant_raise: okf.DescendantRaise) -> None:
|
|
587
|
+
concept_id = descendant_raise.concept_id
|
|
588
|
+
new_rank = okf.SENSITIVITY_ORDER.index(descendant_raise.new_level)
|
|
589
|
+
if concept_id not in best or new_rank > best_rank[concept_id]:
|
|
590
|
+
best[concept_id] = descendant_raise
|
|
591
|
+
best_rank[concept_id] = new_rank
|
|
592
|
+
|
|
421
593
|
for source_id in sorted(source_levels):
|
|
422
594
|
for descendant_raise in resolve_source_raises(
|
|
423
595
|
files, source_id=source_id, level=source_levels[source_id]
|
|
424
596
|
):
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
597
|
+
_offer(descendant_raise)
|
|
598
|
+
|
|
599
|
+
# Second producer (issue #697), merged by the SAME max rule and offered
|
|
600
|
+
# LAST so a strictly higher cited high-water mark wins over a per-Source
|
|
601
|
+
# raise, while an equal one leaves the closure sweep's record in place --
|
|
602
|
+
# the existing "ties resolve to the first" contract, extended by one
|
|
603
|
+
# producer rather than reinterpreted.
|
|
604
|
+
for descendant_raise in resolve_cited_high_water_raises(files):
|
|
605
|
+
_offer(descendant_raise)
|
|
430
606
|
|
|
431
607
|
return [best[concept_id] for concept_id in sorted(best)]
|
|
432
608
|
|