feat(access-control-prover): 9.4 exact evidence paths from summaries

This commit is contained in:
Marius Ciepluch
2026-10-29 18:46:41 +01:00
parent 06d5363ce2
commit f23d3f986d
3 changed files with 293 additions and 3 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
- _Requirements: 3.8, 6.2, 6.3, 7.2_
- _Boundary: Function Summaries, Context Cache_
- _Depends: 9.2_
- [ ] 9.4 Materialise exact evidence paths from summaries
- [x] 9.4 Materialise exact evidence paths from summaries
- Emit concrete entry-to-operation hops only through exact target edges.
- Preserve every complete chain independently and retain candidate-edge diagnostics separately.
- Done when guarded and unguarded paths to one operation remain separate and candidate edges never complete a path.
@@ -8,8 +8,11 @@ stable, so direct and mutual recursion terminate with the accumulated
evidence. Task 9.3 (Reqs 3.8, 6.2, 6.3, 7.2): an exploration budget bounds
the fixpoint; depth, fan-out and deadline cutoffs stop growth, record the
exact cutoff reason and leave the affected summaries incomplete while
preserving the evidence accumulated so far. Summaries are deterministic and
carry no target names beyond fact identifiers (Req 11.7).
preserving the evidence accumulated so far. Task 9.4 (Reqs 3.9, 3.10, 11.4,
11.5): concrete evidence paths run from recognised entries to operations only
through exact target edges, every complete chain stays independent, and
candidate edges are diagnostics that never complete a path. Summaries are
deterministic and carry no target names beyond fact identifiers (Req 11.7).
"""
from collections.abc import Iterable, Iterator, Mapping
@@ -373,3 +376,113 @@ def monotone_summaries(
members = tuple(summaries_by_symbol[member] for member in component)
_stabilise_component(members, summaries_by_symbol, edges, bounded)
return tuple(summaries_by_symbol[summary.symbol] for summary in local)
@dataclass(frozen=True)
class EvidenceHop:
"""One exact caller-to-callee transition with its call site line."""
caller: SymbolId
callee: SymbolId
line: int
@dataclass(frozen=True)
class EvidencePath:
"""A concrete entry-to-operation chain of exact hops (Req 3.9)."""
entry: SymbolId
target: SymbolId
hops: tuple[EvidenceHop, ...] = ()
@dataclass(frozen=True)
class CandidateEdge:
"""An ambiguous or unresolved call kept as a diagnostic (never a path edge)."""
caller: SymbolId
candidates: tuple[SymbolId, ...]
line: int
reason: str
def exact_call_edges(
functions_by_module: Mapping[str, Iterable[FunctionIR]],
index: SymbolIndex,
) -> tuple[EvidenceHop, ...]:
"""Caller-to-callee hops over complete single-target resolutions only."""
hops: set[EvidenceHop] = set()
for module, functions in functions_by_module.items():
for function_ir in functions:
caller = _function_symbol(index, module, function_ir)
for call in function_ir.calls:
target_set = resolve_call(call, module=module, index=index)
if target_set.completeness.is_complete and len(target_set.targets) == 1:
hops.add(EvidenceHop(caller, target_set.targets[0], call.line))
return tuple(sorted(hops, key=lambda hop: (hop.caller.sort_key, hop.line, hop.callee.sort_key)))
def candidate_call_edges(
functions_by_module: Mapping[str, Iterable[FunctionIR]],
index: SymbolIndex,
) -> tuple[CandidateEdge, ...]:
"""Every ambiguous or unresolved call as a diagnostic candidate edge."""
edges: list[CandidateEdge] = []
for module, functions in functions_by_module.items():
for function_ir in functions:
caller = _function_symbol(index, module, function_ir)
for call in function_ir.calls:
target_set = resolve_call(call, module=module, index=index)
if target_set.completeness.is_complete and len(target_set.targets) == 1:
continue
reason = target_set.completeness.reasons[0] if target_set.completeness.reasons else "resolution:unresolved"
edges.append(CandidateEdge(caller, target_set.targets, call.line, reason))
return tuple(sorted(edges, key=lambda edge: (edge.caller.sort_key, edge.line, edge.reason)))
def exact_evidence_paths(
entries: Iterable[SymbolId],
edges: Iterable[EvidenceHop],
*,
operations_by_symbol: Mapping[SymbolId, tuple[Operation, ...]] | None = None,
) -> tuple[EvidencePath, ...]:
"""Materialise every concrete entry-to-operation chain through exact edges.
Req 3.9: each chain carries the call site of every hop. Req 3.10: chains
to the same operation stay independent. Req 11.5: candidate edges never
complete a path, so they cannot make an enumeration look complete.
"""
operations_by_symbol = operations_by_symbol or {}
adjacency: dict[SymbolId, list[EvidenceHop]] = {}
for hop in sorted(edges, key=lambda hop: (hop.caller.sort_key, hop.line, hop.callee.sort_key)):
adjacency.setdefault(hop.caller, []).append(hop)
paths: list[EvidencePath] = []
for entry in sorted(entries, key=lambda symbol: symbol.sort_key):
_walk_exact_paths(entry, entry, (), adjacency, operations_by_symbol, paths, frozenset({entry}))
return tuple(paths)
def _walk_exact_paths(
entry: SymbolId,
symbol: SymbolId,
hops_so_far: tuple[EvidenceHop, ...],
adjacency: Mapping[SymbolId, list[EvidenceHop]],
operations_by_symbol: Mapping[SymbolId, tuple[Operation, ...]],
paths: list[EvidencePath],
visited: frozenset[SymbolId],
) -> None:
"""Depth-first walk emitting one path per operation-holding symbol reached."""
if operations_by_symbol.get(symbol):
paths.append(EvidencePath(entry=entry, target=symbol, hops=hops_so_far))
for hop in adjacency.get(symbol, ()):
if hop.callee in visited:
continue
_walk_exact_paths(
entry,
hop.callee,
hops_so_far + (hop,),
adjacency,
operations_by_symbol,
paths,
visited | {hop.callee},
)
+177
View File
@@ -15,8 +15,12 @@ from openultrasast.semantic.obligations.abstract_domain import Completeness
from openultrasast.semantic.obligations.operations import Discharge, Operation
from openultrasast.semantic.obligations.resolution import SymbolId, SymbolIndex
from openultrasast.semantic.obligations.summaries import (
EvidencePath,
ExplorationBudget,
FunctionSummary,
candidate_call_edges,
exact_call_edges,
exact_evidence_paths,
local_summaries,
monotone_summaries,
)
@@ -499,3 +503,176 @@ def test_cutoff_reasons_join_existing_incompleteness() -> None:
reasons = by_name["helper1"].completeness.reasons
assert "resolution:no_candidate" in reasons
assert "summaries:deadline_cutoff" in reasons
# --- Task 9.4: exact evidence paths (Reqs 3.9, 3.10, 11.4, 11.5) ---
JAVA_ENTRY_CHAIN = """class Handler {
public void handle() {
this.run("job");
}
public void run(String command) {
this.persist(command);
}
public void persist(String payload) {
}
}
"""
JAVA_TWO_ROUTES = """class Gate {
public void enter() {
this.left();
this.right();
}
public void left() {
this.vault();
}
public void right() {
this.vault();
}
public void vault() {
}
}
"""
JAVA_AUDITOR = """class Auditor {
public void check() {
}
}
"""
def _chain_index() -> SymbolIndex:
return SymbolIndex.build(
(
("src/Handler.java", JAVA_ENTRY_CHAIN, "java"),
("src/Auditor.java", JAVA_AUDITOR, "java"),
)
)
def _routes_index() -> SymbolIndex:
return SymbolIndex.build((("src/Gate.java", JAVA_TWO_ROUTES, "java"),))
def _chain_functions() -> dict[str, tuple[FunctionIR, ...]]:
return {
"src/Handler.java": (
FunctionIR("handle", (), 2, 4, (), (CallSite("run", 3, (), (), (), False, 0, receiver="this", arity=1),)),
FunctionIR("run", ("command",), 5, 7, (), (CallSite("persist", 6, (), (), (), False, 0, receiver="this", arity=1),)),
FunctionIR("persist", ("payload",), 8, 9, (), ()),
)
}
def _routes_functions() -> dict[str, tuple[FunctionIR, ...]]:
return {
"src/Gate.java": (
FunctionIR(
"enter",
(),
2,
5,
(),
(
CallSite("left", 3, (), (), (), False, 0, receiver="this"),
CallSite("right", 4, (), (), (), False, 0, receiver="this"),
),
),
FunctionIR("left", (), 6, 8, (), (CallSite("vault", 7, (), (), (), False, 0, receiver="this"),)),
FunctionIR("right", (), 9, 11, (), (CallSite("vault", 10, (), (), (), False, 0, receiver="this"),)),
FunctionIR("vault", (), 12, 13, (), ()),
)
}
def _symbol(index: SymbolIndex, name: str) -> SymbolId:
return next(symbol for symbol in index.by_name[name] if symbol.module == "src/Handler.java")
def _routes_symbol(index: SymbolIndex, name: str) -> SymbolId:
return next(symbol for symbol in index.by_name[name] if symbol.module == "src/Gate.java")
def test_exact_edges_carry_call_site_lines() -> None:
index = _chain_index()
edges = exact_call_edges(_chain_functions(), index)
pairs = {(edge.caller.name, edge.callee.name): edge.line for edge in edges}
assert pairs[("handle", "run")] == 3
assert pairs[("run", "persist")] == 6
def test_entry_chain_emits_hops_from_entry_to_operation() -> None:
index = _chain_index()
edges = exact_call_edges(_chain_functions(), index)
persist_symbol = _symbol(index, "persist")
paths = exact_evidence_paths(
(_symbol(index, "handle"),),
edges,
operations_by_symbol={persist_symbol: (_java_operation("persist", 9),)},
)
assert len(paths) == 1
assert paths[0].entry.name == "handle"
assert paths[0].target.name == "persist"
assert [hop.callee.name for hop in paths[0].hops] == ["run", "persist"]
assert paths[0].hops[0].line == 3
def test_entry_itself_holding_an_operation_emits_a_zero_hop_path() -> None:
index = _chain_index()
edges = exact_call_edges(_chain_functions(), index)
handle_symbol = _symbol(index, "handle")
paths = exact_evidence_paths(
(handle_symbol,),
edges,
operations_by_symbol={handle_symbol: (_java_operation("handle", 3),)},
)
assert paths == (EvidencePath(entry=handle_symbol, target=handle_symbol, hops=()),)
def test_two_complete_chains_to_one_operation_stay_independent() -> None:
index = _routes_index()
edges = exact_call_edges(_routes_functions(), index)
enter = _routes_symbol(index, "enter")
vault = _routes_symbol(index, "vault")
paths = exact_evidence_paths(
(enter,),
edges,
operations_by_symbol={vault: (_java_operation("vault", 12),)},
)
routes = sorted(tuple(hop.callee.name for hop in path.hops) for path in paths)
assert routes == [("left", "vault"), ("right", "vault")]
assert all(path.target == vault for path in paths)
def test_candidate_edges_never_complete_a_path() -> None:
index = _chain_index()
functions = {
"src/Handler.java": (FunctionIR("handle", (), 2, 4, (), (CallSite("check", 3, (), (), (), False, 0, receiver="auditor"),)),)
+ _chain_functions()["src/Handler.java"][1:]
}
edges = exact_call_edges(functions, index)
auditor = next(symbol for symbol in index.by_name["check"] if symbol.module == "src/Auditor.java")
paths = exact_evidence_paths(
(_symbol(index, "handle"),),
edges,
operations_by_symbol={auditor: (_java_operation("check", 1),)},
)
assert paths == ()
diagnostics = candidate_call_edges(functions, index)
assert len(diagnostics) == 1
assert diagnostics[0].caller.name == "handle"
assert diagnostics[0].reason == "resolution:receiver_type_unknown"
assert any(candidate.enclosing_type == "Auditor" for candidate in diagnostics[0].candidates)
def test_exact_evidence_paths_are_deterministic() -> None:
index = _routes_index()
edges = exact_call_edges(_routes_functions(), index)
enter = _routes_symbol(index, "enter")
vault = _routes_symbol(index, "vault")
operations = {vault: (_java_operation("vault", 12),)}
assert exact_evidence_paths((enter,), edges, operations_by_symbol=operations) == exact_evidence_paths(
(enter,), edges, operations_by_symbol=operations
)