sidegraph 0.1.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.
sidegraph/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Sidegraph — own the memory, rent the graph.
2
+
3
+ A decision / lessons layer: an append-only, repo-committed decision store layered as a
4
+ sidecar over a code-graph engine. See CLAUDE.md and ``docs/`` (user docs; the data
5
+ model lives in ``docs/concepts/data-model.md``).
6
+ """
7
+
8
+ from .schema import (
9
+ SCHEMA_VERSION,
10
+ AnchorBinding,
11
+ Decision,
12
+ DecisionKind,
13
+ DecisionStatus,
14
+ Domain,
15
+ DomainStatus,
16
+ Entity,
17
+ Initiative,
18
+ Provenance,
19
+ )
20
+ from .store import Store
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "SCHEMA_VERSION",
26
+ "AnchorBinding",
27
+ "Decision",
28
+ "DecisionKind",
29
+ "DecisionStatus",
30
+ "Domain",
31
+ "DomainStatus",
32
+ "Entity",
33
+ "Initiative",
34
+ "Provenance",
35
+ "Store",
36
+ "__version__",
37
+ ]
sidegraph/anchoring.py ADDED
@@ -0,0 +1,246 @@
1
+ """Multi-anchor resolution at capture (portable core).
2
+
3
+ Given a decision and an anchor reference (name + file), resolve it through a GraphifyReader
4
+ and create AnchorBindings across tiers with graceful degradation (see
5
+ docs/concepts/anchoring.md):
6
+
7
+ - resolved -> Tier-2 leaf (live) + Tier-1 domain/community (live) [+ Tier-0 initiative]
8
+ - ambiguous -> Tier-1 domain/community only (degraded, unless a domain covers it — see
9
+ below); no leaf
10
+ - unresolved -> Tier-2 leaf (orphaned); no community
11
+
12
+ Depends only on the engine-seam interface (reader.resolve -> ResolveResult) and the Store —
13
+ never on Graphify internals.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import PurePosixPath
19
+
20
+ from .engine.reader import GraphifyReader, ResolveResult
21
+ from .schema import AnchorBinding, Descriptor, Relation
22
+ from .store import Entity, Store
23
+
24
+
25
+ def _crosses_suffix(
26
+ ref: Descriptor, entity: Entity, result: ResolveResult, reader: GraphifyReader
27
+ ) -> bool:
28
+ """True when a path-less ref adopted a path-carrying entity but resolved by NAME to a
29
+ node in a different kind of file — the collision sync.py's rebind ladder refuses. False
30
+ for every ordinary case: a ref that carries its own path, an entity without one, or a
31
+ node whose suffix agrees (a same-suffix move IS a move, and sync heals the descriptor on
32
+ its next pass). Unknown node -> False: no evidence of a collision is not evidence of one.
33
+ """
34
+ if ref.file_path or entity.descriptor is None or not entity.descriptor.file_path:
35
+ return False
36
+ node = reader.get_node(result.node_id) if result.node_id else None
37
+ if node is None or not node.file_path:
38
+ return False
39
+ return PurePosixPath(node.file_path).suffix != PurePosixPath(entity.descriptor.file_path).suffix
40
+
41
+
42
+ class AnchorResolution(list[AnchorBinding]):
43
+ """``resolve_and_bind``'s return value: behaves exactly like the ``list[AnchorBinding]``
44
+ it always returned (every existing caller iterates/indexes/``len()``s it as a plain
45
+ list — this stays a drop-in) but additionally carries the underlying
46
+ ``reader.resolve()`` outcome, so a caller that wants per-anchor feedback (Gate-5 finding
47
+ S3: "ambiguous" anchors reported back to the human/agent) doesn't have to re-resolve the
48
+ same ref a second time just to learn WHY no leaf binding was created.
49
+
50
+ ``status``/``candidates`` mirror ``engine.reader.ResolveResult`` exactly (``"resolved"``
51
+ | ``"ambiguous"`` | ``"unresolved"``; ``candidates`` is only non-empty when ambiguous).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ bindings: list[AnchorBinding],
57
+ status: str,
58
+ candidates: list[str],
59
+ ) -> None:
60
+ super().__init__(bindings)
61
+ self.status = status
62
+ self.candidates = candidates
63
+
64
+
65
+ def resolve_and_bind(
66
+ record_id: str,
67
+ ref: Descriptor,
68
+ reader: GraphifyReader,
69
+ store: Store,
70
+ initiative: str | None = None,
71
+ relation: Relation | None = None,
72
+ ) -> AnchorResolution:
73
+ """``relation`` (optional) overrides the default "affects" on the leaf + Tier-1 bindings
74
+ created for THIS anchor (see docs/concepts/anchoring.md#multi-anchor-at-capture); it
75
+ never applies to the Tier-0 initiative binding, which is decision-level rather than
76
+ per-anchor.
77
+
78
+ Returns an :class:`AnchorResolution` — a ``list[AnchorBinding]`` in every respect a
79
+ caller cares about, plus ``.status``/``.candidates`` for callers that want to report
80
+ ambiguous anchors back without a second ``reader.resolve()`` call.
81
+ """
82
+ result = reader.resolve(ref)
83
+ version = reader.graph_version()
84
+ bindings: list[AnchorBinding] = []
85
+ # Explicit kwarg rather than **{"relation": relation}-if-present: splatting a
86
+ # dict[str, Relation] onto AnchorBinding's constructor is exactly as fragile as it looks
87
+ # to a type checker (nothing pins the dict's key set to just "relation"). AnchorBinding's
88
+ # own default is "affects", so passing it explicitly here changes nothing at runtime.
89
+ effective_relation: Relation = relation if relation is not None else "affects"
90
+ # Tier-1 draws on this rather than `result.community` directly: the cross-suffix rail
91
+ # below may reject the resolved node, and a rejected node's community must not be spent.
92
+ effective_community = result.community
93
+
94
+ # Tier-2 leaf (concrete entity) — created when resolved or unresolved (orphaned).
95
+ if result.status in ("resolved", "unresolved"):
96
+ # find+mint half only (design D3): Store.get_or_create_entity runs the lookup and
97
+ # the mint atomically (Task 2), closing finding 2's TOCTOU for this call site. It is
98
+ # NOT a pure get-or-create here, though: on a resolved node the engine mapping
99
+ # (last_seen_node_id/last_seen_graph_version/last_seen_community) must be refreshed
100
+ # even when the entity already existed -- a rebuild routinely renumbers node ids and
101
+ # communities for an entity capture already knows about, and that refresh has to
102
+ # keep landing on the same durable id or sync's rebind logic would never see it move.
103
+ # That refresh — and the second upsert it requires — stays HERE, not inside
104
+ # get_or_create_entity: collapsing it away would silently stop sync's mapping from
105
+ # updating (see tests/test_anchoring_mapping_refresh.py, a characterization test
106
+ # pinning exactly this before this conversion).
107
+ entity = store.get_or_create_entity(ref)
108
+ # Cross-suffix rail, mirroring sync.py's ("a unique cross-suffix hit is a collision,
109
+ # not a move"). A path-less ref that adopted a path-carrying entity can resolve by
110
+ # NAME to a node in a different kind of file — a markdown heading standing in for a
111
+ # code symbol that left the graph. sync's ladder refuses that; capture-time had no
112
+ # counterpart, so adoption redirected the collision onto a REAL entity's engine
113
+ # mapping, where the next rebind orphans without restoring it (external review,
114
+ # finding 2 — pre-fix the same damage landed on a disposable twin, which is why it
115
+ # was invisible). Withholding the refresh is the whole fix: the binding still lands
116
+ # live on the adopted entity, because the decision does name this symbol. Deciding
117
+ # that such an anchor is *unresolved* would be a policy change, not a repair.
118
+ if _crosses_suffix(ref, entity, result, reader):
119
+ # Rejecting the node's id while still spending its community would be half an
120
+ # abstention, and the half that leaks is the durable one: sync re-adjudicates a
121
+ # leaf, but nothing re-adjudicates a Tier-1 community binding — when the leaf
122
+ # later orphans, `_repoint_off_path` sees the vanished file's community (None)
123
+ # and repoints nothing, so the decision keeps surfacing in the DOCS community
124
+ # forever (external review, round 3, probed). Fall back to the entity's own
125
+ # last-known community: evidence this rail has not rejected.
126
+ effective_community = entity.last_seen_community
127
+ elif result.status == "resolved":
128
+ entity.last_seen_node_id = result.node_id
129
+ entity.last_seen_graph_version = version
130
+ entity.last_seen_community = result.community
131
+ store.upsert_entity(entity)
132
+ leaf_status = "live" if result.status == "resolved" else "orphaned"
133
+ bindings.append(
134
+ store.add_binding(
135
+ AnchorBinding(
136
+ record_id=record_id,
137
+ entity_id=entity.entity_id,
138
+ tier=2,
139
+ status=leaf_status,
140
+ relation=effective_relation,
141
+ )
142
+ )
143
+ )
144
+
145
+ # Tier-1: an ACCEPTED domain covering the anchor's current community wins over the bare
146
+ # community entity — new memory lands on durable, named abstractions once domains are
147
+ # ratified (see spec §1/§4). Domain-covered bindings are always "live": once a domain has
148
+ # claimed the community, Tier-1 confidence comes from the domain's curation, not from
149
+ # whether this particular leaf resolved cleanly. No accepted domain claims the community
150
+ # (the case for every store without ratified domains) -> legacy `community:<id>`
151
+ # fallback, unchanged from before this existed.
152
+ if effective_community is not None:
153
+ domain = store.find_domain_by_community(effective_community)
154
+ if domain is not None:
155
+ domain_entity = store.get_or_create_abstract_entity(f"domain:{domain.slug}")
156
+ bindings.append(
157
+ store.add_binding(
158
+ AnchorBinding(
159
+ record_id=record_id,
160
+ entity_id=domain_entity.entity_id,
161
+ tier=1,
162
+ status="live",
163
+ relation=effective_relation,
164
+ )
165
+ )
166
+ )
167
+ else:
168
+ comm = store.get_or_create_abstract_entity(f"community:{effective_community}")
169
+ comm_status = "live" if result.status == "resolved" else "degraded"
170
+ bindings.append(
171
+ store.add_binding(
172
+ AnchorBinding(
173
+ record_id=record_id,
174
+ entity_id=comm.entity_id,
175
+ tier=1,
176
+ status=comm_status,
177
+ relation=effective_relation,
178
+ )
179
+ )
180
+ )
181
+
182
+ # Tier-0 initiative — created only when the decision names one. Never takes the
183
+ # per-anchor relation override (see docstring).
184
+ if initiative:
185
+ init = store.get_or_create_abstract_entity(f"initiative:{initiative}")
186
+ bindings.append(
187
+ store.add_binding(
188
+ AnchorBinding(
189
+ record_id=record_id,
190
+ entity_id=init.entity_id,
191
+ tier=0,
192
+ status="live",
193
+ )
194
+ )
195
+ )
196
+
197
+ return AnchorResolution(bindings, result.status, result.candidates)
198
+
199
+
200
+ def orphan_reason(ref: Descriptor, reader: GraphifyReader) -> str:
201
+ """Why an anchor resolved to nothing — the three causes need three different fixes.
202
+
203
+ - ``"file-not-in-graph"``: the graph carries no node for ``ref.file_path`` at all. The
204
+ NAME may be perfectly correct; the graph simply does not cover that file yet. On the
205
+ airflow corpus this was 9 of 15 orphans — the graph was built 2026-07-29 and the files
206
+ were written 2026-07-30. Fix: ``graphify update .``, then re-anchor. Also covers a
207
+ typo'd path and a file type the engine does not index.
208
+ - ``"name-not-in-file"``: the graph does carry that file, and it has no such name. Fix:
209
+ the name (``find_entity``/``query_structure`` will say what is really there).
210
+ - ``"no-file-path"``: the ref carried a bare name. A name-only ref that resolves to
211
+ nothing cannot be diagnosed further. Fix: pass ``file_path``.
212
+
213
+ Deliberately reader-only: no git call, no filesystem stat. "Is this file in the graph"
214
+ is the question that decides the fix, and the reader answers it directly — reaching for
215
+ a repo root would add a subprocess per capture to sharpen a distinction the author does
216
+ not need.
217
+ """
218
+ if ref.file_path is None:
219
+ return "no-file-path"
220
+ return "name-not-in-file" if reader.nodes_in_file(ref.file_path) else "file-not-in-graph"
221
+
222
+
223
+ def entity_summaries(store: Store, bindings: list) -> list[dict]:
224
+ """``{"entity_id", "canonical_name", "tier"}`` per binding.
225
+
226
+ Lives here rather than in ``server.py`` because ``capture.py``'s propose pipeline needs
227
+ the identical shape for its own ``anchors_orphaned`` bucket and cannot import from
228
+ ``server`` (server imports capture). One definition, so the human-asked path and the
229
+ agent-initiated path cannot drift into reporting the same fact two ways.
230
+
231
+ Lets a caller chain straight into ``find_entity``/``get_entity_history`` without a raw
232
+ store lookup. A binding whose entity has vanished is skipped rather than rendered as a
233
+ hole.
234
+ """
235
+ out: list[dict] = []
236
+ for b in bindings:
237
+ entity = store.get_entity(b.entity_id)
238
+ if entity is not None:
239
+ out.append(
240
+ {
241
+ "entity_id": entity.entity_id,
242
+ "canonical_name": entity.canonical_name,
243
+ "tier": b.tier,
244
+ }
245
+ )
246
+ return out
@@ -0,0 +1,49 @@
1
+ from .model import (
2
+ AcceptedRecord,
3
+ AnchorPlan,
4
+ BootstrapCandidate,
5
+ BootstrapPlan,
6
+ BootstrapReport,
7
+ EditableCandidate,
8
+ EditResult,
9
+ Exclusion,
10
+ HostKind,
11
+ IntegrationResult,
12
+ PlanIssue,
13
+ ProfileDetection,
14
+ ProofResult,
15
+ ProofSelection,
16
+ Reconciliation,
17
+ ReviewAction,
18
+ ReviewedCandidate,
19
+ ReviewResult,
20
+ RunStatus,
21
+ ScanResult,
22
+ SourceFingerprint,
23
+ WarningCode,
24
+ )
25
+
26
+ __all__ = [
27
+ "AcceptedRecord",
28
+ "AnchorPlan",
29
+ "BootstrapCandidate",
30
+ "BootstrapPlan",
31
+ "BootstrapReport",
32
+ "EditResult",
33
+ "EditableCandidate",
34
+ "Exclusion",
35
+ "HostKind",
36
+ "IntegrationResult",
37
+ "PlanIssue",
38
+ "ProfileDetection",
39
+ "ProofResult",
40
+ "ProofSelection",
41
+ "Reconciliation",
42
+ "ReviewAction",
43
+ "ReviewedCandidate",
44
+ "ReviewResult",
45
+ "RunStatus",
46
+ "ScanResult",
47
+ "SourceFingerprint",
48
+ "WarningCode",
49
+ ]