brainiac-cli 0.16.0__py3-none-any.whl
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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/graphify.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Graphify discovery build (GRF-01, ADR-0003 Ruling 6 + Ruling (a)).
|
|
2
|
+
|
|
3
|
+
Periodically builds a derived, DISCOVERY-ONLY graph: nodes = indexed notes,
|
|
4
|
+
edges = explicit wikilinks (``kind: WIKILINK``, exact reuse of
|
|
5
|
+
``brain.graph.build_graph``) plus a capped, scored layer of embedding-neighbour
|
|
6
|
+
proposals (``kind: INFERRED``). Same doctrine as ``brain.graph``:
|
|
7
|
+
``authoritative: false``, never authoritative on its own, and NEVER
|
|
8
|
+
auto-written into a note body — INFERRED edges are candidates for human review
|
|
9
|
+
only (a hot-queue entry / ``brain graphify`` output), gated through
|
|
10
|
+
``egress.apply_gate`` before they reach any surface, exactly like
|
|
11
|
+
``graph_expand`` candidates.
|
|
12
|
+
|
|
13
|
+
ADR-0003 Ruling (a) explicitly SUPERSEDES the earlier "documented only"
|
|
14
|
+
disposition (``core.py`` graphify branch, ``routines/manifest.json`` row
|
|
15
|
+
graphify-discovery) on two grounds, both closed by this module's design:
|
|
16
|
+
|
|
17
|
+
1. *"No clean fold"* — this module gives the maintain branch a single
|
|
18
|
+
function to call, exactly like ``health``/``integrity``.
|
|
19
|
+
2. *"Runtime budget"* — three caps bound the monthly build:
|
|
20
|
+
* **Drift gate** — a corpus manifest keyed by ``(note id, content
|
|
21
|
+
hash)``; unchanged since the last build => a no-op in milliseconds
|
|
22
|
+
(``manifest_unchanged``). The content hash is the SAME
|
|
23
|
+
``content_hash`` column the index's own incremental sync (IDX-03)
|
|
24
|
+
already maintains — this module never re-hashes note bodies.
|
|
25
|
+
* **Embedding reuse** — INFERRED edges are scored from vectors ALREADY
|
|
26
|
+
stored in the index (``note_vectors`` reads the persisted per-chunk
|
|
27
|
+
vectors via the vector backend's ``get_vectors``/``search``
|
|
28
|
+
contract); this module never re-embeds the corpus.
|
|
29
|
+
* **Wall-clock budget** — the caller (``BrainCore.graphify``) times the
|
|
30
|
+
build and flags ``action_required`` past a 5-minute soft ceiling
|
|
31
|
+
(target <=60s at the current corpus scale, ADR-0003 Ruling 6).
|
|
32
|
+
|
|
33
|
+
Provenance note (session s10): ADR-0003 Appendix B pins ONLY
|
|
34
|
+
``90 System/_ppr/ppr.py`` from the reference vault (already reused by s08's
|
|
35
|
+
whole-corpus PageRank in ``brain.graph.revisit_sample``) — no
|
|
36
|
+
``_graphify``-named script is in the fingerprint list. Per the session brief
|
|
37
|
+
this build therefore ports the DESIGN directly from Ruling 6 (this module has
|
|
38
|
+
no upstream file to sha256-verify), not any unverified reference-vault file.
|
|
39
|
+
|
|
40
|
+
INFERRED-edge precision (HARDENED:grill): raw cosine similarity is tempered by
|
|
41
|
+
(a) a small "2-hop bridge" boost when two notes already share a wikilink
|
|
42
|
+
neighbour (a structural signal that they are plausibly related even though
|
|
43
|
+
they are not directly linked), and (b) a small recency boost for two recently
|
|
44
|
+
updated notes. Skip rules: no self-links (guaranteed — pairs are built over
|
|
45
|
+
``i < j`` distinct ids); no already-linked pairs (skipped against the
|
|
46
|
+
explicit wikilink graph); no frontmatter/code-fence "anchors" — moot by
|
|
47
|
+
construction, because INFERRED edges score MEAN CHUNK VECTORS, and chunks are
|
|
48
|
+
built from ``note.body`` which ``frontmatter.parse_text`` has already split
|
|
49
|
+
the YAML frontmatter block out of (``brain.notes.load_note``) before
|
|
50
|
+
``chunk_text`` ever sees it; a code fence inside the body is just more text
|
|
51
|
+
fed to the embedder, not a link anchor, so there is no anchor-parsing path
|
|
52
|
+
for stray frontmatter/code-fence content to leak into.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import datetime
|
|
57
|
+
import math
|
|
58
|
+
from typing import Any
|
|
59
|
+
|
|
60
|
+
from .graph import LinkGraph
|
|
61
|
+
|
|
62
|
+
GRAPH_SCHEMA_VERSION = 1
|
|
63
|
+
PROVENANCE = "graphify-derived (discovery-only)"
|
|
64
|
+
|
|
65
|
+
# ADR-0003 Ruling 6 caps.
|
|
66
|
+
DEFAULT_TOPK = 5 # per-note INFERRED cap (k <= 5)
|
|
67
|
+
DEFAULT_SCORE_FLOOR = 0.72 # fixed cosine threshold for a proposal
|
|
68
|
+
DEFAULT_GLOBAL_CAP_MULTIPLIER = 2.0 # INFERRED <= 2x explicit-edge count
|
|
69
|
+
DEFAULT_BUDGET_SECONDS = 60.0 # ADR target at current corpus scale
|
|
70
|
+
ACTION_REQUIRED_SECONDS = 300.0 # ADR: log action_required past 5 min
|
|
71
|
+
|
|
72
|
+
# HARDENED:grill tempering constants — small, capped nudges; cosine (already
|
|
73
|
+
# floor-gated) stays the dominant term.
|
|
74
|
+
_BRIDGE_BOOST_PER_SHARED = 0.05
|
|
75
|
+
_BRIDGE_BOOST_CAP = 0.15
|
|
76
|
+
_RECENCY_BOOST_CAP = 0.10
|
|
77
|
+
_RECENCY_HALF_LIFE_DAYS = 730.0 # ~2 years: beyond this, no recency boost
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def corpus_manifest(conn) -> dict[str, str]:
|
|
81
|
+
"""Per-note ``id -> content_hash`` manifest. REUSES the index's own
|
|
82
|
+
``content_hash`` column (already maintained by incremental sync's
|
|
83
|
+
path+hash comparison, IDX-03) instead of re-hashing note bodies."""
|
|
84
|
+
rows = conn.execute("SELECT id, content_hash FROM notes").fetchall()
|
|
85
|
+
return {r[0]: r[1] or "" for r in rows}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def manifest_unchanged(old_state: dict[str, Any] | None, new_manifest: dict[str, str]) -> bool:
|
|
89
|
+
"""True iff the corpus identity is unchanged since the last build — the
|
|
90
|
+
drift gate (ADR-0003 Ruling 6, ground 2). ``old_state`` is the persisted
|
|
91
|
+
``manifest.json`` dict (``{"notes": {...}, "generation": N, ...}``)."""
|
|
92
|
+
if not old_state:
|
|
93
|
+
return False
|
|
94
|
+
return old_state.get("notes") == new_manifest
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
98
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
99
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
100
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
101
|
+
if na == 0.0 or nb == 0.0:
|
|
102
|
+
return 0.0
|
|
103
|
+
return dot / (na * nb)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def note_vectors(conn, backend) -> dict[str, list[float]]:
|
|
107
|
+
"""One representative vector per note: the mean of its chunk vectors
|
|
108
|
+
ALREADY stored by the index (embedding reuse — this never re-embeds).
|
|
109
|
+
A note with no indexed chunks (e.g. an empty body) is simply absent."""
|
|
110
|
+
note_rows = conn.execute("SELECT rowid, id FROM notes").fetchall()
|
|
111
|
+
chunk_rows = conn.execute("SELECT rowid, note_rowid FROM chunks").fetchall()
|
|
112
|
+
by_note: dict[int, list[int]] = {}
|
|
113
|
+
for crowid, nrowid in chunk_rows:
|
|
114
|
+
by_note.setdefault(nrowid, []).append(crowid)
|
|
115
|
+
all_chunk_ids = [c for cs in by_note.values() for c in cs]
|
|
116
|
+
vecs = backend.get_vectors(conn, all_chunk_ids) if all_chunk_ids else {}
|
|
117
|
+
out: dict[str, list[float]] = {}
|
|
118
|
+
for note_rowid, note_id in note_rows:
|
|
119
|
+
cvecs = [vecs[c] for c in by_note.get(note_rowid, []) if c in vecs]
|
|
120
|
+
if not cvecs:
|
|
121
|
+
continue
|
|
122
|
+
dim = len(cvecs[0])
|
|
123
|
+
out[note_id] = [sum(v[i] for v in cvecs) / len(cvecs) for i in range(dim)]
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _bridge_boost(link_graph: LinkGraph, a: str, b: str) -> tuple[float, int]:
|
|
128
|
+
"""A small boost when ``a``/``b`` already share a wikilink neighbour (a
|
|
129
|
+
"2-hop bridge" — structural evidence they are plausibly related even
|
|
130
|
+
though no direct link exists yet)."""
|
|
131
|
+
adj = link_graph.undirected_adj
|
|
132
|
+
shared = len(adj.get(a, set()) & adj.get(b, set()))
|
|
133
|
+
return min(shared, 3) * _BRIDGE_BOOST_PER_SHARED, shared
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_date(raw: Any) -> datetime.date | None:
|
|
137
|
+
try:
|
|
138
|
+
return datetime.date.fromisoformat(str(raw)[:10])
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _recency_boost(today: datetime.date, updated_a: Any, updated_b: Any) -> float:
|
|
144
|
+
"""A small boost for two notes both updated recently. Missing/unparsable
|
|
145
|
+
dates contribute no boost (neutral) rather than raising."""
|
|
146
|
+
da, db = _parse_date(updated_a), _parse_date(updated_b)
|
|
147
|
+
if da is None or db is None:
|
|
148
|
+
return 0.0
|
|
149
|
+
avg_age = ((today - da).days + (today - db).days) / 2.0
|
|
150
|
+
frac = max(0.0, 1.0 - avg_age / _RECENCY_HALF_LIFE_DAYS)
|
|
151
|
+
return frac * _RECENCY_BOOST_CAP
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_inferred_edges(
|
|
155
|
+
conn,
|
|
156
|
+
backend,
|
|
157
|
+
link_graph: LinkGraph,
|
|
158
|
+
*,
|
|
159
|
+
today: datetime.date,
|
|
160
|
+
topk: int = DEFAULT_TOPK,
|
|
161
|
+
score_floor: float = DEFAULT_SCORE_FLOOR,
|
|
162
|
+
global_cap_multiplier: float = DEFAULT_GLOBAL_CAP_MULTIPLIER,
|
|
163
|
+
explicit_edge_count: int = 0,
|
|
164
|
+
) -> list[dict[str, Any]]:
|
|
165
|
+
"""Capped, scored embedding-neighbour proposals (ADR-0003 Ruling 6).
|
|
166
|
+
|
|
167
|
+
For each note, probe the vector backend (ANN under sqlite-vec, exact under
|
|
168
|
+
the brute-force fallback — same adapter contract ``near_dup`` already
|
|
169
|
+
uses) with that note's mean chunk vector to NOMINATE candidate neighbours,
|
|
170
|
+
then recompute TRUE cosine between the two notes' own mean vectors
|
|
171
|
+
(backend-independent scoring, mirrors ``BrainIndex.near_dup``). Skip
|
|
172
|
+
rules: no self (guaranteed by construction), no already-linked pair
|
|
173
|
+
(checked against ``link_graph``). A qualifying pair's raw cosine must
|
|
174
|
+
clear ``score_floor``; the final ``score`` additionally tempers cosine
|
|
175
|
+
with a 2-hop bridge boost + a recency boost (HARDENED:grill) and is what
|
|
176
|
+
the greedy cap-respecting selection below ranks by.
|
|
177
|
+
|
|
178
|
+
Caps are enforced ONCE, globally, by a single greedy walk over ALL
|
|
179
|
+
qualifying candidate pairs sorted by final score descending: a pair is
|
|
180
|
+
selected only while BOTH endpoints are still under the per-note ``topk``
|
|
181
|
+
degree AND the running total is still under
|
|
182
|
+
``global_cap_multiplier * explicit_edge_count``. This is what makes the
|
|
183
|
+
per-note cap hold even for a node that is somebody else's top-k neighbour
|
|
184
|
+
without being in its own — inbound proposals are capped exactly like
|
|
185
|
+
outbound ones.
|
|
186
|
+
"""
|
|
187
|
+
vecs = note_vectors(conn, backend)
|
|
188
|
+
ids = sorted(vecs)
|
|
189
|
+
if len(ids) < 2:
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
chunk_to_note = {
|
|
193
|
+
int(crid): nid
|
|
194
|
+
for nid, crid in conn.execute(
|
|
195
|
+
"SELECT n.id, c.rowid FROM chunks c JOIN notes n ON n.rowid = c.note_rowid"
|
|
196
|
+
).fetchall()
|
|
197
|
+
}
|
|
198
|
+
updated_by_id = dict(conn.execute("SELECT id, updated FROM notes").fetchall())
|
|
199
|
+
|
|
200
|
+
probe_k = max(topk * 4, topk + 10)
|
|
201
|
+
seen_pairs: dict[tuple[str, str], dict[str, Any]] = {}
|
|
202
|
+
for a in ids:
|
|
203
|
+
hits = backend.search(conn, vecs[a], probe_k)
|
|
204
|
+
best_per_neighbour: dict[str, float] = {}
|
|
205
|
+
for chunk_rowid, _backend_score in hits:
|
|
206
|
+
b = chunk_to_note.get(int(chunk_rowid))
|
|
207
|
+
if b is None or b == a or b not in vecs:
|
|
208
|
+
continue
|
|
209
|
+
cosine = _cosine(vecs[a], vecs[b])
|
|
210
|
+
if cosine > best_per_neighbour.get(b, -1.0):
|
|
211
|
+
best_per_neighbour[b] = cosine
|
|
212
|
+
# This node's own top-`topk` neighbours by raw cosine, above the floor.
|
|
213
|
+
ranked = sorted(best_per_neighbour.items(), key=lambda kv: -kv[1])[:topk]
|
|
214
|
+
for b, cosine in ranked:
|
|
215
|
+
if cosine < score_floor:
|
|
216
|
+
continue
|
|
217
|
+
if b in link_graph.undirected_adj.get(a, set()):
|
|
218
|
+
continue # already linked — no INFERRED duplicate of a real edge
|
|
219
|
+
key = (a, b) if a < b else (b, a)
|
|
220
|
+
if key in seen_pairs and seen_pairs[key]["cosine"] >= cosine:
|
|
221
|
+
continue
|
|
222
|
+
boost, shared = _bridge_boost(link_graph, a, b)
|
|
223
|
+
recency = _recency_boost(today, updated_by_id.get(a), updated_by_id.get(b))
|
|
224
|
+
score = cosine * (1.0 + boost + recency)
|
|
225
|
+
reason = f"embedding cosine {cosine:.3f}"
|
|
226
|
+
if shared:
|
|
227
|
+
reason += f"; {shared} shared wikilink neighbour(s)"
|
|
228
|
+
if recency > 0:
|
|
229
|
+
reason += "; both recently updated"
|
|
230
|
+
seen_pairs[key] = {
|
|
231
|
+
"kind": "INFERRED", "from": key[0], "to": key[1],
|
|
232
|
+
"cosine": round(cosine, 6), "score": round(score, 6),
|
|
233
|
+
"reason": reason,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
global_cap = int(global_cap_multiplier * explicit_edge_count)
|
|
237
|
+
ordered = sorted(seen_pairs.values(), key=lambda e: -e["score"])
|
|
238
|
+
degree: dict[str, int] = {}
|
|
239
|
+
selected: list[dict[str, Any]] = []
|
|
240
|
+
for edge in ordered:
|
|
241
|
+
if len(selected) >= global_cap:
|
|
242
|
+
break
|
|
243
|
+
a, b = edge["from"], edge["to"]
|
|
244
|
+
if degree.get(a, 0) >= topk or degree.get(b, 0) >= topk:
|
|
245
|
+
continue
|
|
246
|
+
degree[a] = degree.get(a, 0) + 1
|
|
247
|
+
degree[b] = degree.get(b, 0) + 1
|
|
248
|
+
selected.append(edge)
|
|
249
|
+
return selected
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_graph_artifact(
|
|
253
|
+
conn,
|
|
254
|
+
backend,
|
|
255
|
+
link_graph: LinkGraph,
|
|
256
|
+
*,
|
|
257
|
+
today: datetime.date,
|
|
258
|
+
topk: int = DEFAULT_TOPK,
|
|
259
|
+
score_floor: float = DEFAULT_SCORE_FLOOR,
|
|
260
|
+
global_cap_multiplier: float = DEFAULT_GLOBAL_CAP_MULTIPLIER,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""The build proper (sans provenance/generation stamping, which the
|
|
263
|
+
HOST-only caller adds — this stays a pure function of an open index
|
|
264
|
+
connection + backend + a prebuilt wikilink graph)."""
|
|
265
|
+
nodes = [
|
|
266
|
+
{"id": r[0], "type": r[1], "classification": r[2]}
|
|
267
|
+
for r in conn.execute("SELECT id, type, classification FROM notes").fetchall()
|
|
268
|
+
]
|
|
269
|
+
wikilink_edges = [
|
|
270
|
+
{"kind": "WIKILINK", "from": a, "to": b}
|
|
271
|
+
for a, outs in sorted(link_graph.out.items())
|
|
272
|
+
for b in sorted(outs)
|
|
273
|
+
]
|
|
274
|
+
inferred_edges = build_inferred_edges(
|
|
275
|
+
conn, backend, link_graph, today=today, topk=topk, score_floor=score_floor,
|
|
276
|
+
global_cap_multiplier=global_cap_multiplier,
|
|
277
|
+
explicit_edge_count=len(wikilink_edges),
|
|
278
|
+
)
|
|
279
|
+
return {
|
|
280
|
+
"nodes": nodes,
|
|
281
|
+
"edges": wikilink_edges + inferred_edges,
|
|
282
|
+
"corpus": {
|
|
283
|
+
"note_count": len(nodes),
|
|
284
|
+
"explicit_edge_count": len(wikilink_edges),
|
|
285
|
+
"inferred_edge_count": len(inferred_edges),
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def validate_artifact(
|
|
291
|
+
artifact: dict[str, Any], *, topk: int = DEFAULT_TOPK,
|
|
292
|
+
global_cap_multiplier: float = DEFAULT_GLOBAL_CAP_MULTIPLIER,
|
|
293
|
+
) -> tuple[bool, list[str]]:
|
|
294
|
+
"""Schema + cap validation run BEFORE the atomic publish (HARDENED:codex):
|
|
295
|
+
a build that fails this never replaces the consumable ``graph.json``."""
|
|
296
|
+
problems: list[str] = []
|
|
297
|
+
if artifact.get("authoritative") is not False:
|
|
298
|
+
problems.append("authoritative must be false")
|
|
299
|
+
if artifact.get("schema_version") != GRAPH_SCHEMA_VERSION:
|
|
300
|
+
problems.append(f"schema_version must be {GRAPH_SCHEMA_VERSION}")
|
|
301
|
+
if not artifact.get("provenance"):
|
|
302
|
+
problems.append("missing provenance stamp")
|
|
303
|
+
edges = artifact.get("edges") or []
|
|
304
|
+
explicit = [e for e in edges if e.get("kind") == "WIKILINK"]
|
|
305
|
+
inferred = [e for e in edges if e.get("kind") == "INFERRED"]
|
|
306
|
+
cap = global_cap_multiplier * len(explicit)
|
|
307
|
+
if len(inferred) > cap + 1e-9:
|
|
308
|
+
problems.append(f"INFERRED edge count {len(inferred)} exceeds global cap {cap}")
|
|
309
|
+
degree: dict[str, int] = {}
|
|
310
|
+
for e in inferred:
|
|
311
|
+
degree[e["from"]] = degree.get(e["from"], 0) + 1
|
|
312
|
+
degree[e["to"]] = degree.get(e["to"], 0) + 1
|
|
313
|
+
over = {n: d for n, d in degree.items() if d > topk}
|
|
314
|
+
if over:
|
|
315
|
+
problems.append(f"per-node INFERRED cap ({topk}) exceeded: {over}")
|
|
316
|
+
return (not problems), problems
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def top_candidates(edges: list[dict[str, Any]], *, limit: int) -> list[dict[str, Any]]:
|
|
320
|
+
"""Top-``limit`` INFERRED edges by score — the human-review candidate
|
|
321
|
+
list (never auto-written into a note; review-only, hot-queue shaped by
|
|
322
|
+
the caller)."""
|
|
323
|
+
inferred = [e for e in edges if e.get("kind") == "INFERRED"]
|
|
324
|
+
return sorted(inferred, key=lambda e: -e["score"])[:limit]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_published_inferred_edges(graph_json_path) -> list[tuple[str, str]]:
|
|
328
|
+
"""Read the published graph's INFERRED edges as ``(a, b)`` pairs, for the
|
|
329
|
+
OPTIONAL ``graph_expand(..., use_inferred=True)`` consumer (ADR-0003
|
|
330
|
+
Ruling 6, "Optional"). Degrades to ``[]`` on anything short of a valid,
|
|
331
|
+
non-partial artifact — missing file, unreadable JSON, or a stale
|
|
332
|
+
``authoritative``/``schema_version`` stamp — never raises. A build failure
|
|
333
|
+
marker lives at a SEPARATE path (``BUILD_FAILED.json``) by construction,
|
|
334
|
+
so a partial build is never even a candidate for this reader to pick up."""
|
|
335
|
+
import json as _json
|
|
336
|
+
from pathlib import Path
|
|
337
|
+
|
|
338
|
+
p = Path(graph_json_path)
|
|
339
|
+
try:
|
|
340
|
+
artifact = _json.loads(p.read_text(encoding="utf-8"))
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
return []
|
|
343
|
+
if not isinstance(artifact, dict):
|
|
344
|
+
return []
|
|
345
|
+
if artifact.get("authoritative") is not False:
|
|
346
|
+
return []
|
|
347
|
+
if artifact.get("schema_version") != GRAPH_SCHEMA_VERSION:
|
|
348
|
+
return []
|
|
349
|
+
return [
|
|
350
|
+
(e["from"], e["to"]) for e in artifact.get("edges") or []
|
|
351
|
+
if e.get("kind") == "INFERRED" and e.get("from") and e.get("to")
|
|
352
|
+
]
|