Evidence entity model — one identity, explicit joins, typed coverage¶
Origin: An architecture review (2026-09-23) of how the L2 snapshot, the
header graph (buildsource/header_graph.py), and the public-surface graph
(compare/surface_graph.py) relate. Finding: abicheck has a rich L2
snapshot and a useful dependency graph, but they are not yet one
consistently identified, queryable evidence model.
ADR: none new. This plan finishes goals ADR-063
already states (Phase 2 identity, Phase 3 "public surface as a graph query",
Phase 6 SemanticIR) and reuses the release-contract model of ADR-065 and
the "one contract, many providers" section of AGENTS.md. A phase that
changes a snapshot or report schema needs an ADR-063 amendment first.
Type: Initiative plan (cross-cutting: model/, extract/, compare/,
buildsource/, storage/).
Effort: L–XL overall. Expect roughly 8–15 PRs. Risk: Phases 1 and 2
change node IDs and the snapshot schema (SCHEMA_VERSION bump, golden-file
churn). Phases 0, 4 and 5 are additive.
Relationship to other plans: One Semantic Pipeline owns the IR and identity primitives. This plan owns using them as the one join key across L0/L1/L2/L4 evidence. Storage format v2 owns persistence. This plan keeps the versioned JSON snapshot as the external baseline format and does not propose replacing it.
Problem¶
| # | Gap | Consequence |
|---|---|---|
| 1 | Entity identity. The header graph and the public-surface builder can represent one declaration under different node IDs. surface_graph.py uses the parse-time entity_id when present and otherwise falls back to approximate decl:///type:// IDs. Its own module docstring defers unifying the schemes to "a later phase". |
A shared graph container does not guarantee that evidence joins onto one entity. Nodes can be duplicated, and cross-layer queries have to reconcile IDs themselves. |
| 2 | Implicit L0/L1 ↔ L2 joins. Ordinary L2 graphs hold header and type relationships. Exports and debug facts live in separate snapshot sections, or as per-declaration Fact[bool]s (model/surface_facts.py). Rich source↔symbol↔debug joins exist only with L4 evidence. |
A graph traversal alone cannot answer "which public declaration is backed by this export and this debug type?" |
| 3 | Scope and ownership. Public/internal classification comes from header roots plus provenance. An include edge is a different fact. No graph-level owner says which package, component or namespace promises a declaration. | A release-wide header tree compared with one member binary can produce misleading public-vs-export findings (MKL/oneDAL). The release fan-out already solves this through model/release_surface.py, but graph queries cannot see that model. |
| 4 | Coverage per relationship. Coverage is tracked per extractor pass (docs/learn/graph-coverage.md). A generic query must interpret passes itself. |
"No edge" is easily read as "no dependency", especially when comparing L2 with L4. |
| 5 | Derived vs. observed edges. _add_export_edges in compare/surface_graph.py emits exports edges from a declaration's own linker name. They are not matched against the observed export table, as the helper's docstring says. |
Edges that look alike carry very different authority. A persisted derived edge can go stale relative to the snapshot records it was projected from. |
| 6 | Cost of materialization. The snapshot, graph nodes/edges, merge facts and indexes are all Python objects. service_header_graph_attach.py already skips a population pass after measuring it as 44–71% slower on small cases. |
"Put everything in the graph" could worsen the oneDAL memory problem, however well the JSON compresses. |
Goal¶
One canonical entity index, fed by separate evidence producers. Queries run over typed relationships that carry their evidence class and coverage.
flowchart TD
A["L2 header declarations"] --> E["Canonical entity index"]
B["L0 observed exports"] --> E
C["L1 observed debug types"] --> E
D["L4 source and build facts"] --> E
E --> G["Typed relationships and coverage"]
G --> Q["Comparison and impact queries"]
"Fed by" never forces a match. An ambiguous or absent join stays a first-class state. A public inline declaration may have no export, and an export may have no known public declaration. Both are valid.
Invariants (the acceptance contract)¶
- I1 — one ID per entity. Every producer that names a declaration or type
resolves it through the same identity function
(
model/graph_entity_identity.py, whose keys are in bijection with the linker-name tiers of ADR-063'sEntityId; see "Phase 1 — landed"). Two producers that see the same declaration yield the same node ID. An entity with no resolvable identity gets an explicitunresolvednode, never an approximate one that silently collides. - I2 — joins are evidence, not name equality. A declaration's mangled
name is never treated as proof of an observed export. Every cross-layer
edge records its join state:
matched,ambiguous(with candidates),unmatched, orunknown-- the other side was never observed (no export table, no debug section), which is incomplete evidence, never a failed match. - I3 — every edge kind declares its evidence class:
observed(an extractor saw it),resolved_join(two observations were joined under a stated rule) orderived(a projection of snapshot records). Each class names its producer, inputs, and recomputation rule. Aderivededge is recomputed from the records, never trusted when persisted. - I4 — absence is typed. A query for edge kind K in scope S returns
present,proven_absentorunknown.proven_absentrequires the producer of K to have covered S. - I5 — the contract is modelled once. Public roots and any namespace selection are explicit contract inputs. Declarations and exports relate to providers through the existing release-surface model rather than a second one.
- I6 — materialization is opt-in until measured. No graph view becomes unconditional without a peak-RSS and latency measurement on a real large-product dump.
Phases¶
Ordered by how many incorrect conclusions each phase prevents, not by size.
| Phase | Scope | Size | Depends on |
|---|---|---|---|
0 — Evidence-class retag (landed: model/graph_evidence_class.py's EdgeEvidenceClass, compare/surface_graph.py's EDGE_EVIDENCE_CLASS; the linker-name edge is now declares_linker_name/derived; builder edges were confirmed not persisted — production never writes them into AbiSnapshot.surface_graph — so no schema change) |
Add an evidence-class attribute to the surface-graph edge kinds. Rename or retag the linker-name exports edge as derived (for example declares_linker_name). Keep the name exports free for a future observed join. Update tests/test_compare_surface_graph.py. No schema change unless graph edges are persisted; check first. |
S | — |
1 — Identity invariants (landed: model/graph_entity_identity.py is the one node-id function for every declaration/type graph producer; see Phase 1 — landed) |
Small L2/L0/L1 fixtures stating I1 as property tests (the same declaration seen via castxml, clang, DWARF, and the export table → one node). Then route surface_graph.py and header_graph.py node IDs through semantic_ir/EntityId, with explicit alias and unresolved nodes. Migrate readers in the same PR as producers. |
L | ADR-063 Phase 2/6 |
2 — Explicit cross-layer joins (landed: compare/export_join.py, compare/debug_type_join.py, vocabulary in model/graph_join.py; see Phase 2 — landed) |
An observed exports join (export table → entity, via model/export_index.py's projection) and an L1 debug-type → entity join, each carrying I2 join states. Replaces the per-query reconciliation readers do today. |
M–L | 1 |
| 3 — Ownership in the graph (landed, ADR-075 D5/D7; see Phase 3 — landed) | Expose model/release_surface.py providers and contract inputs (public roots, namespace selection) as graph-level owner relations. Wiring, not new design. |
M | 1 |
4 — Coverage-aware queries (landed: compare/edge_query.py, vocabulary in model/edge_coverage.py, L5 pass table in model/source_graph_coverage.py; see Phase 4 — landed) |
A query API returning I4's three-valued answer per edge kind and scope, built on the existing per-pass coverage records and Fact statuses. |
M | 0, 2 |
| 5 — Measure, then decide materialization (landed: measured in "Phase 5 measurements"; its Recommendations 2–4 landed as 5a–5d below) | Profile a real large-product (oneDAL-class) dump with ABICHECK_MEMORY_TRACE and scripts/bench_release_memory.py: snapshot vs. graph vs. index cost. Decide which views to persist, which to compute on demand, and whether to use compact tables or lazy section loading (storage v2 Phase 2). |
S to measure; follow-up TBD | — (can run in parallel) |
| 5a — Lazy graph-section loading (landed) | storage-format-v2 Phase 2 A2.1 for the graph section: decoded on first read of surface_graph / build_source.source_graph (model/lazy_graph.py). A default compare still reads the graph; the L5 diff was deliberately not gated (ADR-062 D8 note). |
S | 5 |
| 5b — Compact graph tables (landed, schema v49) | storage/graph_table_codec.py: interned, columnar node/edge tables; pre-v49 graphs still load. ADR-063 D5 amended. |
M | 5 |
| 5c — Persist observed, derive the rest (landed) | No loader-rederived field and no public-surface-builder projection (RECOMPUTABLE_FACT_PRODUCERS) is stored; projections are rebuilt on demand. |
S | 5b |
| 5d — Re-measure, incl. the multi-library release (landed) | scripts/bench_graph_materialization.py per item and on libonedal.so + libonedal_dpc.so (release mode). Results in "Phase 5 follow-up measurements". |
S | 5a–5c |
Phases 0 and 5 are independent and small, so they can start now. Phase 5's numbers should inform Phases 1–2 before those choose what to materialize.
Phase 1 — landed¶
Landed in abicheck/abicheck#1358 (ADR-063 amended, D3 "graph node identity"). Phases 2 and 3 are now unblocked: both join onto the one key this phase established.
ID schemes before and after¶
| Scheme | Producer | Readers | Before | After |
|---|---|---|---|---|
| Header-graph declaration seed | buildsource/header_graph.py seed_decl |
L5 findings, graph_reconcile, cli_graph |
decl://<mangled or bare name>; a castxml ctor/dtor placeholder used as if it were a linker name; unmangled overloads collapsed onto decl://<name> |
snapshot_identities() table: decl://<linker name>, else explicit unresolved:// |
| AST replay declarations | model.source_graph.function_decl_identity (call/type/override/macro/template graphs, source_edges) |
same | C linkage keyed decl://<qualified>#sha256:<qualType>, so it never met its header node |
delegates to declaration_key: the linker name whenever clang reported one (mangledName == name is the C-linkage symbol); unmangled fallback unchanged |
| L4 source-ABI fold | buildsource/source_graph_build_source_abi.py |
same | decl://SourceEntity.identity() (C linkage: qualified#hash, linker name blanked by the extractor) |
source_entity_decl_node_id: linker name from mangled_name or the new names["linker"]; the identity() spelling recorded as an alias |
| Flat header-graph types (no clang AST) | header_graph._seed_flat_type_node / _flat_structural_type_edges |
L5 findings | type://<bare leaf>: ns::W/other::W one node |
type://<qualified>; a spelling two declarations share becomes two unresolved:// nodes |
| AST / L4 types | header_graph_ast_projection, type_graph, L4 fold |
same | type://<clang qualified name> |
unchanged (= type_identity) |
| Public-surface builder | compare/surface_graph.py |
policy/public_surface_closure.py, export_surface.py |
EntityId.key, fallback declaration::/type::/typedef:: (_approximate_node_id); kinds declaration/type |
same snapshot_identities() table as the header graph; kinds source_decl/record_type/enum_type/typedef; readers look ids up via ReferencedIdentifiers.node_id(). _approximate_node_id and node_id_for_* deleted |
| Second spellings | (none) | — | a second node | SourceGraphSummary.identity_aliases (Mach-O decoration, L4 legacy identity()), persisted |
EntityId.key / OccurrenceId |
header-AST producers | diff matching, finding_identity, semantic_ir |
also a surface-graph node id | unchanged, no longer a graph node id |
ADR-048 CanonicalIdentity, ADR-046 EntityResolver |
model/entity_identity.py, SourceGraphSummary.resolve_entities |
graph_reconcile, graph_impact |
reconciliation keys derived from a node | unchanged: derived from nodes, not node ids |
finding_identity |
finding_identity.py |
dedup, reports | finding ids | unchanged (not graph identity) |
| Kythe/CodeQL ingest | buildsource/graph_backends.py |
L5 | decl://<VName signature> |
unchanged; see gaps |
| Linker-name projection | surface builder _add_linker_name_edges |
none in production (tests, graph views) | symbol://<mangled> + exports edge |
symbol://<mangled> + declares_linker_name edge (derived, Phase 0): the declaration's own linker name, never proof of an export |
| Observed export entries | compare/export_join.py (Phase 2) |
export_surface.py, policy/public_surface_closure.py, surface builder |
— | binary_symbol://<platform>/<spelling> (binary_symbol node, exports edge to the declaration, resolved_join). One node per table entry: the same spelling in an ELF and a Mach-O table is two observations |
| Observed debug types | compare/debug_type_join.py (Phase 2) |
compare/debug_type_scope.py (_diff_dwarf, depth projection), surface builder |
— | debug_type://debug/<record\|enum>/<qualified>[#n] (debug_type node, debug_type_of edge to the header type, resolved_join); #n (n ≥ 2) is a layout-distinct ODR definition, never merged with the first |
AST-seeded bare names (type_graph._decl_identity, call_graph._identity) |
AST passes | the passes' own resolution indexes | index keys, not node ids | unchanged |
What landed¶
- Tests first (strict xfail, then flipped):
tests/test_graph_identity_invariants.py(header graph + surface builder over one graph: one node per declaration, overloads/namespaced/inline-namespace types kept apart, explicitunresolved, Mach-O alias, L4 join, input-order independence),tests/test_graph_identity_invariants_integration.py(one header dumped through castxml, clang and hybrid: one id per entity, identical across frontends),tests/test_graph_entity_identity.py(hypothesis properties of the identity function against generated ground truth). - One identity function,
model/graph_entity_identity.py: linker name, elsequalified#signature(non-callables:qualified), elseunresolved://; types by qualified name; typedefs in the type space except a C tag-namespace clash;UnresolvedOccurrenceskeeps identical unresolved evidence apart;snapshot_identities()is the table both L2 producers read. - Storage: snapshot schema v50,
SourceGraphSummary.schema_version3, persistedidentity_aliases. Old ids cannot be rewritten from what a stored graph carries (the ASTqualTypebehind a signature hash; the scope a bare-leaf node stood for), so a pre-v3/v3 graph pair is reported not compared (compare/source_graph_identity_scheme.py, on the L5 coverage row and as a warning) rather than diffed.
Measured on oneDAL¶
Same setup as the Phase 5 measurements below (libonedal_core.so.3, PyPI
daal/daal-include 2025.10.0 vs 2025.11.0, scripts/bench_graph_materialization.py),
one repeat per variant, main at 87731bc vs this phase:
| Variant | Measure | Before | After |
|---|---|---|---|
graph+facts |
nodes | 110,907 | 69,288 |
graph+facts |
graph section (compact / zstd-3) | 136.4 / 4.71 MB | 120.8 / 4.20 MB |
graph+facts |
snapshot raw | 353.0 MB | 326.7 MB |
graph+facts |
dump parent RSS | 2,157 MiB | 1,946 MiB |
graph+facts |
compare | 367 s / 2,656 MiB | 331 s / 2,383 MiB |
graph (default) |
nodes | 49,523 | 49,264 |
graph (default) |
graph section (compact / zstd-3) | 74.4 / 2.70 MB | 75.6 / 2.81 MB |
- The 29,109
declarationand 3,142typeduplicates are gone.symbolnodes fall from 29,109 to 18,249, because only resolved declarations get a linker-name node. - In the default graph, 259 C-linkage
decl://<name>/decl://<name>#sha256:…pairs merged into one node each. - The default graph section is 1.6% larger. About 11.6k castxml ctor/dtor
placeholder nodes are now explicit
unresolved://nodes: a longer prefix plus anidentityattr. A first cut that also packed theEntityIdkey into those ids measured 81.4 MB and was trimmed. - Both comparisons report the same 5,078 artifact-backed findings.
Remaining documented gaps¶
- PDB/BTF/CTF function/variable identity stays
unresolved(ADR-063 Phase 6): no linker name reaches those records here. - A castxml-only constructor/destructor (synthetic placeholder, no mangling)
was
unresolvedand did not join clang's mangled node. Closed (#1370): castxml 0.7.0 records nomangledattribute onConstructor/Destructorelements, but the export table does.model/special_member_identity.pypairs each placeholder one-to-one with an exported Itanium variant family (exact owner; for a constructor, the demangled parameter list after normalization and typedef lookup) and keys the node on the complete-objectC1/D1spelling clang reports, with the observedC2/D0/D2as aliases the exports join matches as one entity. Stillunresolvedby design: inline or unexported members, templated owners, overloads indistinguishable after namespace-leaf reduction, typedefs reachable only through using-declarations or base classes, and constructors when no demangler is available. - castxml drops an inline-namespace segment (
ns::Svs clangns::v1::S); per G15 the two stay separate without further evidence. - Kythe/CodeQL-ingested nodes keep their VName-signature ids.
- The DWARF (L1) and export-table (L0) sides of I1 are Phase 2's joins onto this key; this phase covers the L2 header-AST producers and L4/L5 replay. (Closed by Phase 2.)
Phase 2 — landed¶
Explicit, evidence-backed joins from L0 (observed export tables) and L1 (observed debug types) onto the Phase 1 identity. This closes the L0/L1 side of I1 and states I2/I3 for both.
What landed¶
- Vocabulary (
model/graph_join.py):JoinState(matched,ambiguous,unmatched,unknown),JoinRecord(candidates, rejected candidates, a stable reason code, with the state/candidate-count invariant enforced at construction),CrossLayerJoin(both sides,complete,edges(),state_counts()), andJoinSpec: each edge kind's evidence class (resolved_join), producer, inputs and recompute rule, inJOIN_SPECS. - Export join (
compare/export_join.py, edgeexports, the name Phase 0 reserved). Left side: every function/variable entity by its I1 node id. Right side: every observed export-table entry, read only throughmodel/export_index.py(ELFall_export_nameswithdefault_versioned_namesmarking what an unversioned link binds to; PEpe_export_ids_with_ordinal_placeholder; Mach-Oall_export_names; every table a snapshot carries, via the newbuild_raw_export_indexes). Rule: the table contains the declaration's linker spelling (mangled, elsename); on a Mach-O table only, Phase 1's one-underscore decoration alias, refused when another declaration owns the shifted spelling exactly. An x86 PE_foo@8decoration carries no alias record and does not join. A declaration isambiguousonly when it joins two entries of one table (one entry per table is one entity observed twice); an export another entity also claims marks each claimantexport_contested. No table at all: every declarationunknown,complete=False. - Debug-type join (
compare/debug_type_join.py, edgedebug_type_of). Left side: every header record/enum entity. Right side: every occurrence inAbiSnapshot.dwarf(DWARF, or BTF/CTF/PDB reduced to it). Rule: identical qualified name and kind, and no contradiction on any layout fact both sides carry (union-ness, size, field offsets, enumerator values). A contradicted candidate goes torejectedwith reasonlayout_conflict; a match recordslayout_corroboratedorlayout_unavailable. castxml's dropped inline namespace stays separate (G15). ODR conflicts are now observed: the DWARF walk keeps every further, layout-distinct definition (DwarfMetadata.struct_odr_conflicts/enum_odr_conflicts), each its own#noccurrence; layout may single one out, otherwise the header entity isambiguous.odr_conflicts_observedsays whether anyone looked (only the DWARF walk does), so a BTF/CTF/PDB shape or pre-v51 snapshot never reads "no conflict" by default. No debug info: every header entityunknown. - Graph: the public-surface builder emits
binary_symbolanddebug_typenodes (every observed subject, orphans included, with itsjoin_state),exportsanddebug_type_ofedges, and anexport_join_stateattr on each declaration node, so an inline declaration with no export is visible as such.declares_linker_namestaysderived. - Tests first (strict xfail, flipped per commit):
tests/test_export_join.pyandtests/test_debug_type_join.py(every join state, each I2 fixture the plan names, both incomplete-evidence cases, eachJoinSpec),tests/test_cross_layer_join_properties.py(hypothesis: ground-truth oracle, input-order independence, no join without a shared spelling, exhaustive states, ODR never merged, plus a vacuity guard on the generators),tests/test_cross_layer_join_integration.py(integration: gcc-g+ castxml through the realdump-> storedcompareCLI; join states, agreement withbinary_exported_facton every header-AST declaration, the DWARF-tier scope change,--contract exportsroots, and a real two-CU ODR conflict),tests/test_dwarf_odr_conflicts_storage.py(v51 round trip and pre-v51 migration).
Readers: which join replaces what¶
| Reader | Before | After |
|---|---|---|
export_surface.py (--contract exports roots, unmatched_exports) |
private _matched_export_names, _macho_shifted_spellings, _exact_export_owners, its own table read in observed_exports_by_platform, set subtraction for leftovers |
the exports join: a root is a declaration whose record is matched or ambiguous; leftovers are the join's unmatched entries; observed_exports_by_platform is a projection of the join's export domain. The private matchers are deleted |
policy/public_surface_closure._seed_undeclared_exports |
default-versioned exports minus every declaration's _symbol_keys (display name, bare tail) |
the join's unmatched default-version entries. Documented fix: a C export foo beside an unrelated ns::foo is undeclared now; the bare tail no longer "declares" it |
diff_platform._diff_dwarf (_allow_name) and policy/depth_projection (_allow_dwarf_name, _public_dwarf_scope) |
a debug name in scope when it, or its last :: segment, equalled any header record's bare name |
compare/debug_type_scope.py: a debug name in scope when the debug-type join names a same-kind header entity with it (joined or layout-rejected) on either side, minus types opaque in both. Documented fix: a private impl::Foo beside a public api::Foo is no longer diffed, so it no longer shows up as a filtered out-of-surface finding. Both callers share the function |
contract_evidence_collect.py |
reads observed_exports_by_platform for the provider digest; its own decl: node keys for the replay type graph |
digest now over the join's export domain (same content). Its decl:/record: replay-graph keys are not export reconciliation and are unchanged (gap below) |
workflows/crosscheck_ownership.py, compare/bundle_export_index.py |
no per-declaration matcher: run-scoped ownership and symbol -> member providers over default_versioned_names |
unchanged: provider ownership across members is Phase 3's |
extract/surface_fact_producers.py (binary_exported_fact) |
extraction-time lookup in the dynamic+static export sets (castxml/clang) or dwarf_snapshot._is_exported |
unchanged, persisted observation. The join must not contradict it: the integration test asserts matched iff binary_exported_fact is true on every castxml declaration |
Measured on oneDAL¶
Same operands and invocation as "Phase 5 measurements" (libonedal_core.so.3,
PyPI daal/daal-include 2025.10.0 vs 2025.11.0, daal.h through
daal_all.hpp, -I include -I include/dal), 4 vCPU / 15 GiB, Python 3.13,
castxml 0.7.0, one run per cell, main at 577d856 vs this phase:
| Variant | Build | Dump s (old / new) | Dump parent RSS | Compare s | Compare parent RSS | Graph section (compact / zstd-3) |
|---|---|---|---|---|---|---|
graph |
base | 90.0 / 87.9 | 898 MiB | 125.0 | 951 MiB | 9.9 MB / 0.97 MB |
graph |
Phase 2 | 92.2 / 93.4 | 896 MiB | 124.5 | 950 MiB | 9.9 MB / 0.97 MB |
graph+facts |
base | 98.9 / 100.4 | 965 MiB | 124.4 | 946 MiB | 9.9 MB / 0.97 MB |
graph+facts |
Phase 2 | 101.5 / 100.5 | 985 MiB | 123.4 | 949 MiB | 9.9 MB / 0.97 MB |
Join cost and states on the stored snapshots (old / new):
| Join | Time | Left (declarations / header types) | Right (exports / debug types) |
|---|---|---|---|
exports |
0.20 / 0.23 s (+0.09 s identity table) | 2,494 / 2,495 matched, 11,687 unmatched, 0 ambiguous | 2,494 / 2,495 matched, 10,858 / 10,864 unmatched |
debug_type_of |
0.003 s | 1,618 unknown |
none |
- Nothing new is persisted for these operands: stored nodes/edges and the
graph section are unchanged, since join edges are recomputed on demand and
the PyPI wheels carry no DWARF (so no ODR observation is written either).
Dump/compare time and RSS are within run-to-run noise; the
graph+factsdump's +20 MiB is the builder materializing the join nodes/edges. - The debug join is honestly
unknownfor every header type: a stripped wheel has no debug section, and the join never reports that as "all unmatched" (I4-ready). - Of 14,181 declaration entities, 11,687 join no export and 10.9k exports join no declaration. This run did not break those orphans down by cause; it is a baseline for Phase 4's coverage queries.
- Both comparisons report the same 2,675 findings (verdict COMPATIBLE).
Remaining documented gaps¶
Status after the gap-closing round (2026-09-25). Each gap got its own PR; "closed" means a PR with the fix and a bug-class test, "kept" means the reason below was checked and recorded.
- Backfill onto the debug-type join — closed
(#1380).
backfill_dwarf_layoutnow pairs records under the join's own rule (qualified spelling, no layout contradiction, mutually unique), shared frommodel/debug_type_match.py; the bare-name/suffix matcher is deleted. Measured on the clang backend with-g: 142 gcc-built catalog dumps 168 -> 215 records backfilled, yaml-cpp 0.8.0 (git tag 0.8.0, CMake Debug-g -O0) 46 -> 60, no record lost its backfill. Every gained record has no data members, which the old field-name corroboration refused and reported as a mismatch. Records nested in class templates stay unmatched (clang's pattern scope has no template arguments, DWARF's instantiation scope does). binary_exported_factvs the export join — closed (#1368). Decision in an ADR-063 dated note: the fact stays a separate observation, but every producer (castxml, clang, DWARF) classifies throughmodel.export_index.match_export. OnlyDYNAMIC, the join's own rule, isPRESENT(True);.symtab-only, bare-name and demangled-only hits arePARTIAL(True)with anexport-match:<tier>diagnostic (surface_facts.binary_export_match), so they keep counting as exported and no finding moves. No schema change. Whether the weaker tiers should stop counting as exported is a separate policy question. The castxml constructor/destructor placeholders #1370 resolves stayNOT_COLLECTEDfor the fact while the join now matches them: an unknown, not a contradiction.- Findings-dedup name bridges — kept, both.
diff_helpers.record_canonical_namescannot make a false bridge: a header finding carriesChange.qualified_namefrom its matchedRecordTypepair, which outranks the name table, and a type unqualified on both sides has a global competitor on each side, so the table never registers it (checked over an exhaustive 4,096-case two-snapshot enumeration; forcing the table empty changed no test).debug_type_joinwould add no identity here and would lose dedup where header and binary layout disagree. The table is effectively inert and a candidate for a separate cleanup.buildsource/source_link.py's L4 matcher joins source-replay declarations, a different left side from the export join, and needs constructor/destructor clone folding, synthesized-symbol attribution and a demangled rematch that the export join deliberately refuses. - x86 PE decoration — closed
(#1367).
graph_entity_identity.pe_c_decoration_base(since design-hardening Phase 3: themodel/name_decoration/pe_x86.pycodec, read throughexport_index.pe_decoration_aliases) relates_foo@N,@foo@N,foo@@Nand_footofooonIMAGE_FILE_MACHINE_I386(foo@@Non every machine since Phase 3), never on a C++-mangled name; the join applies it like the Mach-O shift (refused when another declaration owns the spelling, a non-one-to-one collapse staysambiguous).@Nis checked for shape only, not against parameter sizes, which L2 does not carry reliably. MinGW i386__Zexports are not undecorated. - PDB/BTF/CTF function/variable identity stays
unresolved(ADR-063 Phase 6), so those declarations join an export only through their recorded spelling. Their debug shapes do not observe ODR conflicts. - Replay type graph node keys — closed
(#1369). The
compare --contractreplay graph keys every declaration and type by its I1 id, withname:/alias:spelling tiers owned bypolicy/contract_graph_encoding.py(report schema 5.6,contract_evidenceschema 2). A stored schema-1 context is read under its own encoding and never remapped, since adecl:key does not say which entity it merged; replaying a stored pre-change report reproduces its decisions. Cost on the FP corpus: about 2x graph nodes/edges, +7.5% persisted context bytes, no decision changed.
Join counts after the round (oneDAL)¶
Same operands and invocation as above, dumped with all five gap PRs merged on
main at 42cb529 (castxml backend):
| Join | main (old / new) | after the round (old / new) |
|---|---|---|
exports, declarations matched / unmatched |
2,494 / 11,687 · 2,495 / 11,687 | 3,276 / 10,905 · 3,281 / 10,901 |
exports, exports matched / unmatched |
2,494 / 10,858 · 2,495 / 10,864 | 3,794 / 9,558 · 3,799 / 9,560 |
debug_type_of |
1,618 unknown |
1,618 unknown |
binary_exported_fact PRESENT:True / PRESENT:False / NOT_COLLECTED |
2,494 / 7,422 / 4,265 | unchanged |
The export-join gain is the castxml constructor/destructor families #1370 resolves (782 declarations, 1,300 exports on the old side). The PE, fact and replay changes cannot move ELF counts, and the backfill does not run on the castxml backend.
Findings on the same operands: main reports 2,671 (COMPATIBLE), and so
does the round with all five PRs merged. An earlier #1370 head added one
spurious public_reachability_changed on BatchBase: 2025.11 began exporting
the unchanged BatchBase(ParameterType*) constructor's C2, and because the
castxml placeholder was resolved from exports alone, its node id flipped from
unresolved:// to decl://…C1 between versions. #1370 now also resolves
placeholders from the header AST's own C1/D1 manglings
(buildsource/ast_special_members.py), which do not depend on what the binary
exports, and records the export-only id as an alias; a generated test pins
that an export-only change never flips an unchanged member's identity.
Resolving from the AST adds cost: a standalone join_exports call on
oneDAL takes about 0.8 s instead of 0.33 s.
Phase 3 — landed¶
Ownership as graph relations over the Phase 1 keys, on top of the target-ownership plan's Phase 2 (per-entity ownership recorded in the snapshot). Decided by ADR-075 D5/D7.
What landed¶
- Relations (
compare/ownership_relations.py), every entity endpoint an I1 node id:
| Edge | From → to | Evidence class | Inputs / recompute rule |
|---|---|---|---|
owned_by |
declaration/type → owner://<owner> |
derived |
each entity's persisted ownership_fact; recomputed on demand, never persisted |
in_contract |
declaration/type → contract://<contract> |
derived |
same |
provided_by |
binary_symbol://<platform>/<spelling> (the Phase 2 node) → release_member://<member> |
resolved_join |
the release model's own BundleExportIndex; recomputed per release comparison, never persisted |
Why derived. Classification needs the declaring file as the frontend
saw it, the project root and castxml's artificial bit, so it runs once at
extraction and is recorded per entity. The edge only projects that record
onto the node id, so by I3 it is recomputed from the record; persisting it
would add a copy that can go stale and nothing else (Phase 5's rule). An
unclassified entity gets no edge and answers unknown.
- Graph: the public-surface builder emits owner/contract nodes and
the two derived edges (EDGE_EVIDENCE_CLASS). Nothing new is persisted.
- Typed absence (Phase 4): owned_by/in_contract are queryable
through compare/edge_query.py. Their producer is the ownership stamp
(ownership_stamp[classified_declarations]): absence is proven_absent
only for an entity the stamp classified; an unclassified entity, a node two
entities disagree on, or a snapshot with no recorded extraction scope (a
binary-only or pre-v52 dump) answers unknown, and the report's
relationship-coverage section lists it.
- Contract inputs (I5): CompatibilityEvaluationConfig.surface.ownership
records the target roots (-H directories and scope.public_header_dirs,
each its own D7 field), the dependency roots, the private headers and
namespaces and dependency_evidence, with provenance
(workflows/ownership_contract_inputs.py), persisted in the
contract-context receipt when stated.
- Readers moved onto the relations:
| Reader | Before | After |
|---|---|---|
buildsource.cross_source_checks._check_public_not_exported (scalar, per member) |
public-header ScopeOrigin only |
also asks in_contract: a private/external declaration owes no export |
workflows.release_surface_acquisition.surface_from_snapshot (release obligations) |
same private predicate | the same in_contract query, so the scalar path, a one-member package and a release agree |
policy.release_contract_reconciliation.reconcile_side (who provides an obligation) |
BundleExportIndex.providers directly |
the provided_by relation over that index; BundleExportIndex.satisfies deleted |
workflows.crosscheck_ownership |
run-scoped "release owns public_not_exported" |
unchanged, deliberately: it holds no derivation of ownership or providers (it states which level answers a check), so there was nothing private to move. The attribution it defers to is now the provided_by relation above |
- Release classification: member dumps and the acquired release surface
are classified under the one project config
(
workflows.ownership_request.project_ownership_scope, a run-scopedContextVar, the mechanismcrosscheck_ownershipalready uses), folded intoSurfaceAcquisitionIdentity(version 2). - Found and fixed on the way: the release surface was acquired with
compare's defaultlang="c++"read as an explicit request, so a C header tree was parsed as C++ while every member parsed it as C, and every C declaration was listed undermissing_exportsby its C++ mangling. It now follows the member rule (tests/test_release_surface_language_rule.py). - Tests:
tests/test_ownership_relations.py(edges on I1 ids, unknown vs unresolved, conflicting records, evidence classes, the obligation predicate, provider relation keys, D7 provenance and receipt round trip, a hypothesis order-independence property), andtests/test_release_ownership_integration.py(integration, real directorycompare): the MKL/oneDAL shape with one member, with two, and a one-member package matching the scalar path.
Measured on oneDAL¶
Measured 2026-09-25 with scripts/bench_graph_materialization.py
(graph variant, one repetition, 4 vCPU / 15 GiB host, cold caches),
oneDAL 2025.10.0 -> 2025.11.0. Base is the ADR commit (#1364), candidate is
this stack's head. One sample per row, so a difference under ~10% is noise.
| Step | Base time | Candidate time | Base peak RSS | Candidate peak RSS |
|---|---|---|---|---|
single dump OLD (libonedal_core.so.3) |
70.0 s | 78.6 s | 1041.8 MiB | 1042.4 MiB |
single dump NEW |
73.7 s | 77.9 s | 1042.1 MiB | 1046.5 MiB |
single compare |
77.1 s | 76.4 s | 691.9 MiB | 710.7 MiB |
| release: 4 member dumps | 53.3-58.9 s | 53.3-55.0 s | 609-645 MiB | 611-646 MiB |
release compare (stored) |
42.7 s | 41.6 s | 589.8 MiB | 594.0 MiB |
release compare (live directory) |
201.1 s | 187.7 s | 1517.5 MiB | 1740.6 MiB |
Snapshot size: +0.2% single (127.94 -> 128.21 MB), +0.09% per release
member (55.44 -> 55.49 MB) -- the extraction_scope block and its interned
decision table.
Per-owner counts, NEW single snapshot (the bench passes a header file and
no .abicheck.yml, so there is no target root and nearly everything is
unresolved, which is the ADR's documented answer, not a guess): functions
13,818 unresolved / 4 toolchain; types 1,171 unresolved / 9 toolchain;
enums 438 and variables 360 unresolved.
Findings: the release reports (live and stored) are identical member by
member, including all 7 missing_exports and 1,980 shared findings. The
single-library compare drops exactly 4 public_not_exported findings --
__atomic_add_fetch, __atomic_load_n, __atomic_store_n,
__atomic_sub_fetch: compiler builtins now classified toolchain/external,
which owe no export (ADR-075 D6). That is the documented fix; nothing else
moved.
The live-release peak RSS rose 15% (+223 MiB) in this single sample and is
not attributed: the stored-release path, which runs the same
reconciliation, did not move. It is recorded as a gap below, to be
re-measured with ABICHECK_MEMORY_TRACE and three repetitions before it is
called either a regression or noise.
Remaining documented gaps¶
- ~~The live-release RSS increase above is unexplained (one sample).~~
Measured and attributed (B4): not an ADR-075 regression. Same oneDAL
release (
libonedal.so.3+libonedal_dpc.so.3, 2025.10.0 -> 2025.11.0),scripts/bench_graph_materialization.py --repeat 3, base #1364 vs8d417ed(after this round's Phase 3/4 work): live-directory compare parent peak 1,417/1,444/1,338 MiB vs 1,281/1,313/1,762 MiB -- two of three candidate runs below every base run; the original +223 MiB was one draw from a ~±200 MiB spread (two members dumping concurrently, so which phases overlap moves the peak).ABICHECK_MEMORY_TRACEon one live compare per commit puts all growth in the header-graph projection (dump.header_graph.project_streaming) and bisects it: sampled parent RSS 978 MiB at #1364, 1,002 MiB after the ADR-075 implementation (#1376/#1377, within noise), 985-1,020 MiB through #1380, then 764 MiB sampled / 1,061 MiBVmHWMat #1383 (its graph-memory work; 83 s vs 142 s). The stored-release path never moved (589.8 -> 594.0 MiB above; 1,161 MiB +-1 on8d417edwith the header graph attached). Findings identical at every point (964 across both members). Before #1383's ancestor 42cb529 the trace carried noparent_rss_peak_bytes, so only sampled RSS is comparable across the whole bisect. - ~~A release's member dumps never receive
lang_explicit.~~ Fixed (B1):service.run_comparetakeslang_explicitonto itsCompareRequest, and the pairwise fan-out (its thread-pool runner moved toworkflows/keyed_thread_pool.pyto stay underno_growth) and the stranded-member dump forward it. A one-member release now yields the same findings as the scalar pair undercompile.lang: c++(tests/test_release_lang_explicit.py, real g++ fixture where C and C++ layout of an empty struct differ). scope.private_namespacesnarrows the contract but is not merged intopolicy.internal_namespaces(ADR-075 D7.1): that key scopes findings, which is the retention phase's decision.- ~~Ownership is recorded only for snapshots extracted through
resolve_inputand the release surface.~~ Fixed (B2): the header-onlydump(and a typedDumpRequestwith no path), bothcompatlive dumps (compat.run_inputs.finish_live_compat_dump) andappcompat's dumps now callclassify_extracted.tests/test_ownership_entry_points.pyinventories every producer call site inabicheck/with how its result is stamped (or why it needs none: binary-only, inner layer,json.dump), so a new site fails until classified. A pre-v52 baseline still loads with no scope and every decision unset --unknown, never a guessed owner; the readers'ScopeOriginfallback stays, labelled as the weaker tier in thepublic_not_exportedcoverage row (OWNERSHIP_UNRECORDED_NOTE) and asno_extraction_scopeon the graph's ownership coverage record. - ~~
provided_bykeys a release on one platform.~~ Fixed (B3):BundleExportIndex.member_platformsrecords each member's table kind andProviderRelations.edges()keys every edge on its own member'sbinary_symbol://<platform>/…id;platform="mixed"survives only as a side-wide summary (tests/test_provided_by_member_platform.py, ELF+PE, ELF+Mach-O and a generated mix). No report renders these edges yet; the reconciliation'sproviders()attribution was platform-independent and is unchanged.
Phase 4 — landed¶
Invariant I4, "absence is typed": a query for edge kind K in scope S
about a subject answers present, proven_absent or unknown, and
proven_absent requires a producer of K whose coverage includes every
queried scope unit.
What landed¶
- Vocabulary (
model/edge_coverage.py):EdgeAnswer,ProducerRun(ran,partial,not_run,failed),CoverageRecord(a producer's scope units, what it covered, a stable reason and the snapshot field it was read from) andEdgeQueryResult(the answer plus the records it rests on, JSON-serializable for reports and replay). - One rule (
compare/edge_query.decide): an observed edge ispresentwhatever the coverage; otherwiseproven_absentiff every queried unit is covered by some record; elseunknown. A failed or not-run producer covers nothing, so it can only ever yieldunknown. - Query API (
compare/edge_query.EdgeEvidence.query(edge_kind, subject, target=, scope=)) over every kind inEDGE_EVIDENCE_CLASSand the L5 kinds:
| Kind | Class | Absence rests on |
|---|---|---|
exports (declaration subject) |
resolved_join | one record per export table the snapshot owes. A carried block with no entry and no machine/filetype/cpu_type is failed (never parsed), not an empty table |
exports (export subject) |
resolved_join | the header AST; partial (library headers only) under the default dependency filtering, so a toolchain export (std::, __gnu_cxx::, read off the mangling) answers unknown |
debug_type_of |
resolved_join | the debug section (not_run on a stripped binary); the header AST for a debug-occurrence subject |
declares, references |
observed / resolved_join | the header AST; references is failed for a subject whose type spelling the index drops as ambiguous (surface_graph.type_spelling_index, split out so both read one rule) |
declares_linker_name |
derived | the snapshot records it projects |
L5 (DECL_CALLS_DECL, type-graph kinds, COMPILE_UNIT_INCLUDES_FILE) |
observed | model/source_graph_coverage.pass_coverage_records: extractor = ran, narrowed = partial over its compile units, degraded = failed, a header-only pass partial (header-written bodies only) for calls/references, no flag = not_run |
- One owner for the L5 pass table:
source_graph_findings.py's_DEPENDENCY_EDGE_FAMILIES/_HEADER_PASS_ALIAS/_HEADER_FULL_VISIBILITY_KINDS/_pass_trusted_kindsandmodel/source_graph.finalize's literals are now views ofmodel/source_graph_coverage.py; the header pass-name constants moved there too. - Report (schema 5.4):
edge_coverageper side and kind (records,absenceunder the default scope, and answer counts for the two joins); Markdown/HTML "Relationship Coverage" section listing only the kinds whose absence isunknown(report/edge_coverage_section.py/report/render_edge_coverage.py). ADR-063 amended. - Tests first (strict xfail, flipped by the API commit):
tests/test_edge_coverage_query.py(producer not run / partial / failed, stripped binary, exhaustive {ran, not_run, partial, failed} × {edge present/absent} × {in/out of scope} matrices for an L5 pass and the export tables, against hand-written truth tables),tests/test_edge_coverage_properties.py(hypothesis: order independence, more evidence never flipspresent, weakening a record only turnsproven_absentintounknown; the oracle re-derives from record fields),tests/test_edge_coverage_readers.py,tests/unit/report/test_edge_coverage_report.py.
Inventory: readers that treated "no edge" as absence¶
44 production sites were read. Classified:
| Site | Conclusion drawn | Class | Now |
|---|---|---|---|
diff_platform._diff_elf_deleted_fallback |
symbol missing from NEW .dynsym → BREAKING |
wrong: an unparsed ElfMetadata() made every export deleted |
asks export_table_covered |
policy/depth_projection._exported_symbol_names |
declaration not in the table → dropped from a --depth binary view |
wrong: same, stripped every declaration | same |
buildsource/cross_source_checks_base._exported_symbol_names |
declaration not exported → HIGH PUBLIC_NOT_EXPORTED |
wrong: same | same |
diff_symbols._detect_newly_deleted_functions |
not exported before → suppress a DWARF deletion | wrong: an OLD side with no read table | both tables must be read |
policy/contract_conflicts.detect_exported_but_undeclared |
no header declares an export → conflict | wrong: header side never checked | exports query, scope={headers} |
policy/public_surface_closure._seed_undeclared_exports |
unmatched export → undeclared (demotes findings) | wrong: header side never checked | same |
source_graph_findings._call_reachability_findings |
callee set difference → finding | wrong: no pass check (narrowed/degraded/header-only) | source_graph_covers |
export_surface (exclusion_is_provable, _unexplained_exports), contract_evaluation exports/public decisions, release_contract_reconciliation, undeclared_exports, diff_platform_elf_symbols, diff_platform._diff_dwarf, public_surface_query, cross_source_checks_coherence, _check_rtti_for_internal_type, _symbol_owner_findings |
various | correct: each checks resolvable/complete/machine/has_dwarf or intersects both sides |
unchanged; exclusion_is_provable is a closure judgement over the same export-table record, kept as its one owner |
source_graph_findings _dependency_kinds_covered, _common_dependency_edge_kinds, include-graph gates |
pass flags, with an edge-presence fallback for unflagged graphs | correct, with a legacy caveat | table now read from the model owner; fallback kept (gap below) |
compare/debug_type_scope, export_transition suppression guard, ExportJoin.entries_in_state |
a debug type with no header candidate is not diffed; an OLD not-exported suppression | undecidable (false-negative direction) | gap below |
oneDAL orphans by cause¶
Same operands as "Phase 2 — landed" (libonedal_core.so.3, PyPI
daal/daal-include 2025.10.0 vs 2025.11.0, daal.h through
daal_all.hpp, -I include -I include/dal, castxml 0.7.0), classified by
scripts/export_orphan_breakdown.py (first match wins; the exports query
decides unknown first). The counts reproduce Phase 2's baseline exactly.
Declarations with no export (11,687 / 11,687):
| Cause | Count |
|---|---|
| inline or header-only | 7,009 |
compiler-generated (implicit, = delete, pure virtual, __atomic_* builtins) |
2,465 |
| hidden visibility | 991 |
| template, not instantiated | 0 (castxml records none) |
.symtab-only |
0 (the binary has a .symtab; no remaining orphan's linker name is in it) |
| versioned alias | 0 |
| internal namespace | 0 |
| unexplained — a structor castxml records only by placeholder (see gap) | 1,222 |
| unknown | 0 (the export table was read) |
Exports with no declaration (10,858 / 10,864):
| Cause | Count |
|---|---|
| compiler-generated (vtable/typeinfo/VTT/thunks, structor variants) | 8,283 / 8,289 |
internal namespace (::internal:: kernels) |
1,769 |
| template instantiation of a header template | 790 |
truly undeclared (proven_absent) |
12 |
unknown (toolchain std:: exports, dependency headers filtered) |
4 |
| versioned alias | 0 |
Matching gap found: 4,265 declaration orphans are constructors/
destructors whose castxml record carries a placeholder spelling
(__abicheck_ctor__ns::T(...), ~ns::T) and no linker name: 2,995 are
inline, 1,270 are not (the 1,222 "unexplained" plus some counted as
hidden). On the export side about 4,942 C1/C2/D0/D1/D2 structor
symbols join no declaration and are counted under compiler-generated. They
are the same entities: the export join matches linker spellings exactly
and a placeholder has none. Handed to the Phase 2 gaps work (the
export_join owner); buildsource/ctor_export_match.py already matches
structor variants for the L4 link and is the natural rule to reuse.
Cost: the join takes 0.21–0.22 s and the queries over every declaration and
export 1.8–1.9 s per side; edge_coverage_report (every compare) adds the
same per side. scripts/bench_graph_materialization.py --variants graph
--repeat 1 on this branch (same host class as "Phase 2 — landed", one run,
so within run-to-run noise): dump 66.3 / 71.1 s at 1,034 / 1,038 MiB parent
RSS, stored/stored compare 74.9 s at 690 MiB. Both are below Phase 2's
figures, but the base also moved (#1362's canonicalization speed-up), so no
Phase 4 delta is attributable beyond the ~2 s per side measured directly.
Remaining documented gaps¶
- The structor placeholder gap above (export join; Phase 2 gaps owner).
- A1, export fact decided from the read — fixed
(#1384).
extract/surface_fact_producersrecordedbinary_exported_fact = present(False)whenever a binary was supplied, even if its export set came back empty. The builders' shared tail (extract/export_table_read.finish_binary_snapshot) and snapshot load now apply one rule,model.export_index.snapshot_export_table_state, which isedge_query's ownexportscoverage rule moved tomodel/(a table is read when it holds an entry or its header fields show a parse). An unread (default or parse-failed) block makes every missFAILED; a parsed binary exporting nothing stays a confirmed empty table, exactly as the coverage record already said. A pre-v46Visibility.HIDDENkeeps itsPARTIAL(False)reading when the table was read (it was assigned from a lookup against that table) and becomesFAILEDwhen it was not; a stored baseline is reconciled on load, so live and stored operands agree. No finding moved on the FP corpus, the tier-accuracy corpus (both JSON reports byte-identical to base) or the goldens. oneDAL (libonedal_core.so.3,graphvariant, one run, base e2baed5 vs this fix):binary_exported_fact2,494/2,495PRESENT:True, 7,422PRESENT:False, 4,265NOT_COLLECTEDper side on both; the same 5,078 findings; dump 67.3/68.9 s -> 69.6/68.2 s at ~874 MiB, compare 71.6 -> 72.1 s (noise). Its table is read, so nothing is withdrawn. - A4, unread release member — fixed (PR A4).
bundle_export_index.member_export_namesand the compactBundleSignatureEvidence.export_namesprojection now read throughmodel.export_index.read_default_export_names: a default or parse-failed block isNone(unread), so the member lands inmembers_without_exports, coverage goes incomplete and its obligations areunresolved, nevermissing. A parsed empty table stays complete. Tested over platform × read state × full/compact evidence, and through the release section's compute/render pair. - A5, L5 producer coverage — fixed (PR A5). The five producers now
stamp passes (
source_abi— degraded when the L4 replay reports a declaration family failed/partial — with a header-onlyheader_declarationscounterpart forSOURCE_DECLARES;build_targets;build_options), and the mapping-drift, public-reachability, generated-closure, build-option and target-dependency findings require the edge-lacking side'sproven_absent(edge_query.source_graph_covers). The legacy edge-presence reading is retired for flagged graphs insource_graph_covers,_dependency_kinds_coveredandSourceGraphSummary.finalize'scoverage.*.collected. An unflagged graph (hand-built, or stored before this change) keeps edge presence only as evidence that a kind was collected at all; every absence on it isunknown, shown in the coverage section, andcoverage.pass_flags_recordedsays which reading applies. Consequence recorded, not hidden: a stored baseline whose L5 graph predates this change reports none of those five finding families until regenerated. No schema bump was needed (pass flags already persist). - A3, header coverage for the L1 type scope — fixed (PR A3). The clang
#errorretry's dropped headers were only logged. They are now recorded (ast_toolchain["header_parse_excluded"],model/header_parse_coverage, with an AST-cache sidecar so a warm run keeps them); the header-AST coverage record readspartialcovering nothing; the dependency scope no longer strips a DWARF type merely because no parsed header names it (it drops only confirmed dependency types); and each such debug type answersunknownin the "Relationship coverage" section. A type is therefore never dropped silently. Kept as is:debug_type_scopestill does not diff an unnamed debug type (that would re-admit every internal type the scope exists to remove); the answer isunknown, reported, not a diff. A snapshot dumped before the record, and a castxml dump (castxml fails the whole parse rather than dropping a header), read as complete, as before. - A2, OLD-side suppression guard — fixed (PR A2).
export_transition.surface_exit_is_evidence_gapread "not confirmed exported" as "not exported", so an unread OLD table suppressed a real export loss. The removal paths now pass the OLD table asedge_query.ObservedExportTable; the guard suppresses only on its typedproven_absentanswer (or OLD's own confirmed-absent fact), and a bare name set refuses on aFAILEDfact. An OLD with no binary owes no table and still suppresses. Tested by an OLD-table-state × caller-kind table and throughchecker.compare. - C1, schema enum — fixed (PR C1). The producer was right: with no OLD
snapshot,
schema_staleness_statusisnot_evaluated, like every sibling context status. The schema enum (report schema 5.7) and the merge scale gained it;run_outcome.assuranceis now validated against theanalysis_assuranceshape in the compare and audit (1.6) schemas.tests/test_report_schema_conformance.pyvalidates every compare mode's report and checks enum-vs-producer vocabulary.
Tests¶
- Phase 1 needs primitive-level property tests of the identity function
(order-independence, no merge without shared identity evidence, no
collision of an unresolved node onto a resolved one), per
AGENTS.md's "Primitive-level property tests". - Phase 2 needs fixtures for each join state: a public inline declaration
with no export (
unmatched, not a missing export), an export with no declaration, and two declarations competing for one export (ambiguous). (Landed: see "Phase 2 — landed".) - Phase 4 needs an oracle test: an edge kind whose producer did not run
answers
unknownand neverproven_absent. (Landed: see "Phase 4 — landed".)
Out of scope¶
- Replacing the JSON snapshot format. Storage optimization follows once I1–I4 settle the semantics.
- Expanding the graph vocabulary beyond what I1–I5 require.
- Function/variable identity for PDB/BTF/CTF (ADR-063 Phase 6's documented
gap). Phase 1 treats such entities as
unresolvedrather than inventing identity.
Phase 5 measurements¶
Measured 2026-09-23/24 with scripts/bench_graph_materialization.py
(instrumentation only; no production behavior changed).
What was measured¶
- Operands: real oneDAL,
libonedal_core.so.3(113 MB) from PyPIdaal/daal-include2025.10.0 vs 2025.11.0. Public headerdaal.hthrough a one-linedaal_all.hppwrapper (the CLI has no language flag and a.hroot parses as C),-I include -I include/dal. Default castxml backend; the header graph uses the separate clang pass. - Host: 4 vCPU, 15 GiB, Linux 6.18, Python 3.13, castxml 0.7.0.
- Variants:
none(_HEADER_GRAPH_ENABLED/_INCLUDES_ENABLEDoff),graph(today's default attach),graph+facts(plusbuild_public_surface_facts, the pass_attach_header_graphskips; the populated graph is what gets serialized). - Steps, each in its own process with cold private
ABICHECK_CACHE_DIRandXDG_CACHE_HOME:dumpOLD,dumpNEW, stored/storedcompare. Three repeats. A fourth, separate run per variant usedABICHECK_MEMORY_TRACE_TRACEMALLOCfor attribution only.
Results (mean of 3; ± is max deviation from the mean)¶
| Variant | Dump s (per side) | Dump parent RSS | Compare s | Compare parent RSS |
|---|---|---|---|---|
none |
37.6 ± 0.7 | 683 MiB | 111.0 ± 0.6 | 738 MiB |
graph |
119.0 ± 8 | 1,481 MiB | 222.6 ± 5 | 1,756 MiB |
graph+facts |
147.6 ± 9 | 2,216 MiB | 312.2 ± 17 | 2,720 MiB |
Peak RSS varied by < 6 MiB across repeats. Process-tree RSS and PSS equal
parent RSS within 5 MiB in every run (tree PSS ≈ parent − 4 MiB): the cost
is Python in the abicheck process, not a child compiler. Cgroup
memory.current peak minus its launch value was 792 / 2,675 / 3,370 MiB
for the three dumps in the first repeat, but drifted down by up to 1.6 GB
in later repeats as the container's page cache grew. It is reported but
not relied on. memory.peak is the cgroup's lifetime maximum and cannot
be attributed to one run at all.
| Variant | Nodes | Edges | Snapshot raw | Snapshot zstd-3 | Graph section (compact / zstd-3) |
|---|---|---|---|---|---|
none |
0 | 0 | 110 MB | 1.46 MB | — |
graph |
49,872 | 102,388 | 251 MB | 4.59 MB | 79 MB / 2.81 MB |
graph+facts |
111,232 | 180,151 | 364 MB | 6.99 MB | 142 MB / 4.83 MB |
graph nodes: 39,734 source_decl, 9,442 record_type, 532 header,
164 file. The facts pass adds 29,109 declaration, 29,109 symbol and
3,142 type nodes, plus 31,801 declares, 16,853 references and 29,109
exports edges. That is one derived symbol node and exports edge per
declaration with a linker name (gap 5), and a second node for each
declaration the header graph already holds as a source_decl (gap 1).
Attribution (tracemalloc run, dump OLD)¶
- The header-graph attach took ~55–57 s of the ~119 s
graphdump. Of that, ~220 s under tracemalloc (proportionally ~48 s untraced) is streaming the clang AST, with a 677 MiB Python allocation peak inside the streaming projection. The graph build itself is ~16 s. - The retained graph is ~132 MiB of Python allocations (193 MiB phase peak) and grows live objects from 0.95 M to 1.66 M.
build_public_surface_factstakes 3.8 s untraced and retains another ~78 MiB (333 MiB phase peak), taking live objects to 2.36 M.- The rest of the RSS difference (~800 MiB per dump for
graph, a further ~735 MiB forfacts) is the serialized JSON, whose raw size grows 2.3× and 3.3×, plus allocator arenas that are not returned after the AST stream. It is not held by the graph objects. - In
compare, the graph is paid for twice: decoding two 79–142 MB graph sections and diffing them. That doubles compare time (+112 s) and adds ~1 GiB RSS; the facts add a further +90 s and ~960 MiB. The comparison verdict came out the same (exit 0) in all three variants.
Recommendation¶
- Do not make
build_public_surface_factsunconditional, and do not persist its output (I6 fails for it). It adds 30% dump time, +735 MiB dump RSS, +40% compare time and +960 MiB compare RSS, for data that is a pure projection of snapshot records. By I3 such data must be recomputed rather than trusted when persisted anyway. Compute it on demand for the query that needs it, aspolicy.public_surface_closurealready does. - The persisted header graph is the real cost and should become a view, not an always-on section. On oneDAL it roughly triples dump time, doubles dump RSS, and doubles compare time and RSS. Most of that is JSON encode/decode of a 79 MB section, not the 132 MiB live graph. Phases 1–2 should not add node kinds to it until the cost is cut:
- Lazy section loading (storage v2 Phase 2) is warranted. A compare that does not query the graph should not decode it. That alone removes most of the +112 s / +1 GiB compare delta.
- Compact tables are warranted for the graph section. It compresses 28:1 (79 MB → 2.8 MB zstd), i.e. it is dominated by repeated IDs and keys, which interned, columnar node/edge tables remove.
- Phase 1 (one ID per entity) should remove the duplicate
declaration/source_declnodes rather than add a third ID scheme. That is also a size win. - Keep observed evidence persisted, derive the rest. Persist nodes and
edges an extractor observed (header, include, type and call passes),
since recomputing them needs the clang AST, which costs ~50 s here.
Compute
derivededges (exports-from-linker-name,declares/referencesprojections) on demand from the snapshot. - Re-measure before any unconditional change with the same script on
the multi-library oneDAL release (
libonedal.so,libonedal_dpc.so), where member count multiplies these figures.
Phase 5 follow-up measurements (5a–5d)¶
Measured 2026-09-24 with scripts/bench_graph_materialization.py on the
same operands and invocation as "Phase 5 measurements" (PyPI
daal/daal-include 2025.10.0 vs 2025.11.0, daal.h through
daal_all.hpp, -I include -I include/dal), on a host of the same shape
(4 vCPU, 15 GiB, Python 3.13, castxml 0.7.0). One run per cell; every cell
was re-measured for the base commit on this host, so compare rows with each
other rather than with the earlier section's means. The script now imports
the checkout it lives in, so "base" is the script run from a worktree of the
pre-change commit.
Single library (libonedal_core.so.3)¶
| Build | Variant | Dump s (old / new) | Dump parent RSS | Dump tree PSS | Compare s | Compare parent RSS | Compare tree PSS |
|---|---|---|---|---|---|---|---|
| base | graph |
161.1 / 162.6 | 1,481 MiB | 1,474 / 1,477 MiB | 301.2 | 1,757 MiB | 1,753 MiB |
| 5a | graph |
154.6 / 156.3 | 1,446 / 1,478 MiB | 1,441 / 1,473 MiB | 283.3 | 1,753 MiB | 1,748 MiB |
| 5b | graph |
125.9 / 123.6 | 961 / 958 MiB | 956 / 954 MiB | 176.8 | 1,002 MiB | 997 MiB |
| 5c | graph |
126.6 / 126.5 | 938 MiB | 934 / 933 MiB | 178.4 | 971 MiB | 966 MiB |
| base | graph+facts |
210.4 / 210.5 | 2,215 / 2,213 MiB | 2,210 / 2,208 MiB | 450.8 | 2,707 MiB | 2,702 MiB |
| 5c | graph+facts |
136.5 / 143.0 | 1,001 / 1,005 MiB | 997 / 1,001 MiB | 181.5 | 981 MiB | 976 MiB |
| Build | Variant | Snapshot raw | Snapshot zstd-3 | Graph section (compact / zstd-3) | Nodes / edges stored |
|---|---|---|---|---|---|
| base | graph |
250.6 MB | 4.59 MB¹ | 79.1 MB / 2.81 MB¹ | 49,872 / 102,388 |
| 5b | graph |
134.2 MB | 2.54 MB | 11.2 MB / 1.00 MB | 49,872 / 102,388 |
| 5c | graph |
125.9 MB | 2.46 MB | 9.8 MB / 0.95 MB | 49,872 / 102,388 |
| base | graph+facts |
356.2 MB | 6.97 MB | 138.3 MB / 4.81 MB | 111,232 / 180,151 |
| 5c | graph+facts |
125.9 MB | 2.46 MB | 9.8 MB / 0.95 MB | 49,872 / 102,388 |
¹ From the earlier "Phase 5 measurements" run (same operands); this host's base snapshots were lost before their zstd size was read.
- 5a alone saves ~4% of dump time (the shared graph is no longer
encoded twice on save) and ~6% of compare time, with no RSS change. As
the reader inventory predicted, a default compare still reads both graphs
(L5
diff_source_graph_findings, theprivate_header_leakandpublic_to_internal_dependencycross-source checks, assurance_graph_completeness, the content digest), so it saves no decode on that path; a--depth binary/debugcompare decodes nothing (tests/test_lazy_graph_loading.pyobserves this with a decoder spy). - 5b is the step change: the graph section shrinks 8× (79 → 11 MB) and the graph decode itself from 7.6 s to 4.7 s (interned tables plus a load-scoped identity-normalization memo). Dump time −22%, dump RSS −35%, compare time −41%, compare RSS −43% against base.
- 5c removes the rest of the derived data (9.8 MB section). Its main
effect is on
graph+facts: the builder's projections are no longer persisted, so a graph with them saves to the same bytes as one without, andgraph+factscompare falls from 451 s / 2.7 GiB to 182 s / 981 MiB. - Verdict and findings unchanged. The base and 5c compare reports are
identical after removing wall-clock fields and work-directory paths
(2,675 findings, verdict
COMPATIBLE), forgraphandgraph+facts.
Multi-library release (libonedal.so.3 + libonedal_dpc.so.3)¶
Header oneapi/dal.hpp through a one-line dal_all.hpp wrapper,
-I include, for both members (no SYCL macro, so the DPC library is
compared against the host API). Release mode dumps each member, compares
the stored directories, then runs the live directory compare.
| Build | Step | Seconds | Parent RSS | Tree PSS |
|---|---|---|---|---|
| base | dump per member (4) |
153.5–159.5 | 1,483–1,486 MiB | 1,479–1,481 MiB |
| 5c | dump per member (4) |
114.1–117.8 | 835–837 MiB | 831–833 MiB |
| base | stored/stored directory compare |
426.5 | 3,090 MiB | 3,085 MiB |
| 5c | stored/stored directory compare |
126.1 | 1,049 MiB | 1,044 MiB |
| base | live directory compare |
370.4 / 410.2² | 1,797 / 1,792 MiB | 1,792 / 1,787 MiB |
| 5c | live directory compare |
374.7 / 351.3² | 1,815 / 1,470 MiB | 1,811 / 1,465 MiB |
² Two runs each (the first release run of each build had no stored step).
- The stored release compare is where the member count multiplies the old
cost: 3.4× faster and 2.9× less memory. Its report is identical to the
base build's (both members
COMPATIBLE_WITH_RISK, 1,993 and 2,928 risk findings). - The live fan-out never serializes a graph, so 5a–5c cannot change it; the spread above is run-to-run noise.
- The
ERRORmembers were caused by the benchmark, not by the release workers. In three of the four live runs above, one member (a different one each time) endedERRORwith CPython's../Objects/tupleobject.c:911: bad argument to internal function. That is_PyTuple_Resizerefusing a tuple whose refcount is not 1. The extra reference came from this script's own attach hook, which recordedlen(gc.get_objects())from inside each fan-out worker. That list references every GC-tracked object, including a tuple another worker is still building intuple(<generator>)(the victim here wasqualified_name_segments_walk._walk_rewrite_strings, during a member's dump). CPython 3.13 checks the eval breaker after aCALL, so the GIL can pass to the builder while that list is still alive. The census now goes throughmemory_trace.gc_object_count(), which returnsnullwhen any other Python thread exists.tests/test_gc_census_thread_safety.pyholds the invariant and rejects any other first-party heap census. Measured on this host (4 vCPU, 15 GiB, cold caches,graphvariant, this script's child):
| Harness | Live runs | Runs with an ERROR member |
Seconds per run |
|---|---|---|---|
| before (census in workers) | 4 | 2 (libonedal.so.3, then libonedal_dpc.so.3) |
204–211 |
after (gc_object_count) |
10 | 0 | 195–212 |
plain abicheck compare (no harness) |
6 | 0 | 177–276 |
All ten post-fix reports equal the stored/stored directory compare of
per-member dumps (122 s of dumps + 75 s of compare) after normalising the
stored member names' .json suffix. Both members read
COMPATIBLE_WITH_RISK, with identical findings. The fix costs nothing
measurable, because the release path itself never changed.
- Audit of state shared between release workers. Checked: every
functools.lru_cache on the member path (C-level, thread-safe, pure
keys); the locked caches (policy/type_spelling,
compare/spelling_match_cache, compare/spelling_pattern_registry,
model/graph_identity's normalize memo, model/lazy_graph's decode-once
lock, extract/cache_header_scan's counters,
workflows/release_surface_acquisition's ledger); compare/detection_memo
(a ContextVar, so per worker); and zstd (a fresh
ZstdCompressor/ZstdDecompressor per call, never shared). The one
unlocked candidate, demangle's _BATCH_CACHE_OK/_BATCH_CACHE_FAIL
(an in-then-[] read, and FIFO eviction), is safe on a GIL build: no
eval-breaker point sits between those operations, so no other thread can
run in the gap. A 6-thread stress test with the bound shrunk to 4 entries
confirmed it. It would need a lock under free-threaded CPython, which CI
does not run.
Outcome against the recommendations¶
build_public_surface_factsstays opt-in and is never persisted (5c); I6 holds.- Lazy loading (5a) and compact tables (5b) landed for the
graphsection. With 5c, agraphdump on this host went from 162 s to 127 s and 1,481 to 938 MiB, and the graph section is 9.8 MB compact instead of 79 MB. - Observed evidence is persisted; derived fields and projections are recomputed (5c).
- Re-measured on the multi-library release (5d, above).