Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions libs/openant-core/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,13 @@ class ApplicationContext:
# file is handed to the pipeline — the precondition for comparing them.
# Provenance of a repo-supplied threat model, for scan-artifact visibility.
# Both are additive/defaulted so save_context(asdict)/load_context(**data)
# round-trip unchanged. sha256 is over the raw file bytes; permissive_warnings
# is warn_permissive_threat_model's output, which was previously discarded.
# round-trip unchanged. #546 generalizes source_sha256 to the
# DETERMINISTIC-DERIVATION identity of whichever arm produced the
# context: the threat-model file's raw bytes (that arm), the override
# file's content (the manual arm), or the gathered CONTEXT sources'
# digest (the LLM arm) — the checkpoint family's resume keys fold it;
# permissive_warnings is warn_permissive_threat_model's output, which
# was previously discarded.
source_sha256: str | None = None
permissive_warnings: list = None
threat_model_version: int | None = None
Expand Down Expand Up @@ -389,6 +394,27 @@ def gather_context_sources(repo_path: Path) -> dict[str, str]:
return sources


def context_sources_digest(sources: dict[str, str]) -> str:
"""#546: the deterministic-derivation identity over the gathered
CONTEXT sources — the fingerprint the checkpoint family folds into its
resume keys (via the artifact's source_sha256).

Hashes ONLY the repo-derived CONTEXT_FILES entries and the two
degradation markers — NOT ``[directory_structure]`` (it lists the
in-repo output dir, so hashing it would self-invalidate on every
resume that writes a new artifact) and NOT ``[detected_patterns]``
(an rglob-order, cap-windowed list — not canonical). Those two stay a
named residual: a rename that flips the LLM's classification without
touching the hashed sources does not invalidate.
"""
import hashlib
entries = {k: v for k, v in sources.items()
if not k.startswith("[directory_structure]")
and not k.startswith("[detected_patterns]")}
payload = json.dumps(entries, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def get_directory_structure(repo_path: Path, max_depth: int = 2) -> str:
"""Get directory tree for pattern recognition.

Expand Down Expand Up @@ -580,6 +606,18 @@ def _application_context_from_override(data: Any, filename: str) -> ApplicationC
)
data["override_warnings"] = override_warnings
data["override_filename"] = filename
# #546: the override arm's deterministic input IS the override file —
# sha over the raw content (the family folds it via the artifact).
# UNCONDITIONAL: the override data is repo-supplied; a supplied
# source_sha256 must never pin the resume identity (#546's review
# round — the identity is derived, never adopted).
import hashlib as _h
try:
_raw = json.dumps(data, sort_keys=True, separators=(",", ":"))
data["source_sha256"] = _h.sha256(
_raw.encode("utf-8")).hexdigest()
except (TypeError, ValueError):
data["source_sha256"] = None
known = {f.name for f in fields(ApplicationContext)}
unknown = [k for k in data if k not in known]
if unknown:
Expand Down Expand Up @@ -835,6 +873,13 @@ def generate_application_context(
f"Response: {response_text}")

data['source'] = 'llm'
# #546: stamp the deterministic-derivation identity — the checkpoint
# family's resume keys fold this (via the artifact), so a repo edit
# that changes the context derivation invalidates the stale records
# while the LLM's own re-narration does not re-pay. UNCONDITIONAL:
# model output is untrusted — a supplied source_sha256 (steered by
# the scanned repo's sources text) must never pin the identity.
data['source_sha256'] = context_sources_digest(sources)

# Allowlist-filter to dataclass fields: the LLM can hallucinate unknown/extra
# keys, and a raw ApplicationContext(**data) would raise an uncaught TypeError
Expand Down
29 changes: 20 additions & 9 deletions libs/openant-core/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ def _count_verdicts(results):
return counts


def _analyze_fingerprint(binding) -> dict:
def _analyze_fingerprint(binding, ctx_sha=None) -> dict:
"""Build the analyze-phase backend-identity fingerprint.

The static system + user-analysis templates are rendered with
Expand All @@ -465,7 +465,14 @@ def _analyze_fingerprint(binding) -> dict:
lambda: get_system_prompt(app_context=None),
lambda: get_analysis_prompt(code="", language="code", app_context=None),
])
return fingerprint_for_binding(binding, texts)
# #546: the context's deterministic-derivation identity joins the KEY
# (the narration stays excluded — this is the SOURCES hash, not the
# text). None (no context / a pre-#546 artifact) leaves the key
# member absent-equivalent; a changed derivation archives the stale
# records and re-pays.
return fingerprint_for_binding(
binding, texts,
extra_key=({"ctx_sources_sha256": ctx_sha} if ctx_sha else None))


def _archive_stale_results(output_dir: str, current_fp: str) -> None:
Expand Down Expand Up @@ -605,12 +612,22 @@ def run_analysis(
binding = registry.get("analyze")
print(f"[Analyze] Provider: {binding.provider_name}, Model: {binding.model}", file=sys.stderr)

# #546: the application context loads BEFORE the I2 fingerprint — the
# adopt gate folds the context's deterministic-derivation identity
# (source_sha256), so the prior order (fingerprint-then-load) would key
# on None forever.
app_context = None
if app_context_path and HAS_APP_CONTEXT and os.path.exists(app_context_path):
app_context = load_context(Path(app_context_path))
print(f"[Analyze] App context: {app_context.application_type}", file=sys.stderr)

# I2 adopt gate: BEFORE loading any prior checkpoints, verify the backend
# identity that produced them matches the current one. A changed model /
# provider / adapter / static template archives the stale dir aside and
# forces a re-run rather than silently adopting another backend's verdicts.
# Run AFTER the checkpoint.dir override above.
analyze_fp = _analyze_fingerprint(binding)
analyze_fp = _analyze_fingerprint(
binding, ctx_sha=getattr(app_context, "source_sha256", None))
checkpoint.sync_identity(analyze_fp)
# Preserve a prior scan's final report before this run overwrites it.
_archive_stale_results(output_dir, analyze_fp["key_digest"])
Expand All @@ -619,12 +636,6 @@ def run_analysis(
# route through the same provider+model.
json_corrector = JSONCorrector(binding)

# Load application context if provided
app_context = None
if app_context_path and HAS_APP_CONTEXT and os.path.exists(app_context_path):
app_context = load_context(Path(app_context_path))
print(f"[Analyze] App context: {app_context.application_type}", file=sys.stderr)

# Load dataset
print(f"[Analyze] Loading dataset: {dataset_path}", file=sys.stderr)
dataset = read_json(dataset_path)
Expand Down
10 changes: 8 additions & 2 deletions libs/openant-core/core/backend_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@
* NO endpoint DETECTION / drift_log / strict-mode — over-built, excluded.
* NO credential-derived value (api_key). A credential-routing gateway (same
URL + model, different key → different upstream) is a NAMED RESIDUAL.
* app_context / threat-model is LLM-generated and regenerates
* app_context / threat-model NARRATION is LLM-generated and regenerates
non-deterministically every scan; callers MUST render templates with
``app_context=None`` so it never enters ``templates_sha`` (including it
caused a VERIFIED ~17k-token spurious re-pay on a same-config resume).
#546 (scheme 2) amends the clause: the DETERMINISTIC-DERIVATION identity
of the context (the artifact's ``source_sha256`` — the threat-model
file's bytes, the override's content, or the gathered CONTEXT sources'
digest) DOES belong in the key via ``extra_key["ctx_sources_sha256"]`` —
the narration stays excluded; the derivation invalidates stale records
when the repo shape that produced the context changes.
* Generation parameters (``max_tokens``, ``temperature``, etc.) are
deliberately NOT global KEY members (#242: "this config-only change does
not invalidate prior checkpoints. Rationale is FN-safe: pre-fix empty
Expand Down Expand Up @@ -74,7 +80,7 @@

# Bump when the KEY definition below changes so pre-existing sidecars invalidate
# cleanly (they will simply mismatch and trigger an archive-and-repay).
FINGERPRINT_SCHEME_VERSION = 1
FINGERPRINT_SCHEME_VERSION = 2

# Sidecar filename written into each checkpoint dir. Excluded from every
# checkpoint counter (see core/checkpoint.py and the Go DetectFallback).
Expand Down
8 changes: 8 additions & 0 deletions libs/openant-core/core/llm_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,8 @@ def _projection_sha(unit: dict) -> str:
# I2 adopt gate: BEFORE loading prior checkpoints, verify the backend
# identity that produced them matches (a changed model/provider/
# adapter/template archives the stale dir and forces a re-run).
_ctx_sha = ((app_context or {}).get("source_sha256")
if isinstance(app_context, dict) else None)
llr_fp = fingerprint_for_binding(
binding,
render_template_texts([
Expand All @@ -526,6 +528,12 @@ def _projection_sha(unit: dict) -> str:
units_block="",
)
]),
# #546: the context's deterministic-derivation identity —
# the per-prompt app-context block stays excluded (the
# narration non-determinism trap); the SOURCES hash
# invalidates the stale records when the derivation changes.
extra_key=({"ctx_sources_sha256": _ctx_sha}
if _ctx_sha else None),
)
checkpoint.sync_identity(llr_fp)
prior_records = checkpoint.load()
Expand Down
6 changes: 6 additions & 0 deletions libs/openant-core/core/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ def run_verification(
verify_binding, _verify_texts,
extra_key={
"analyze_fingerprint": experiment.get("analyze_fingerprint"),
# #546: the context's deterministic-derivation identity — a
# standalone verify can be given a different --app-context than
# analyze used; the fold keeps the family's keys consistent.
**({"ctx_sources_sha256":
getattr(app_context, "source_sha256", None)}
if getattr(app_context, "source_sha256", None) else {}),
# #287: the verify phase's generation budget is part of its
# checkpoint identity — a budget change invalidates verify
# checkpoints specifically (no scheme bump; the extra_key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def fake_run_detection(units, binding, json_corrector, app_context,

monkeypatch.setattr(analyzer_mod, "_run_detection", fake_run_detection)
monkeypatch.setattr(analyzer_mod, "_analyze_fingerprint",
lambda binding: {"key_digest": "sha256:test"})
lambda binding, ctx_sha=None: {"key_digest": "sha256:test"})

from utilities.llm import PhaseBinding

Expand Down Expand Up @@ -358,7 +358,7 @@ def fake_run_detection(units, binding, json_corrector, app_context,

monkeypatch.setattr(analyzer_mod, "_run_detection", fake_run_detection)
monkeypatch.setattr(analyzer_mod, "_analyze_fingerprint",
lambda binding: {"key_digest": "sha256:test"})
lambda binding, ctx_sha=None: {"key_digest": "sha256:test"})

from utilities.llm import PhaseBinding

Expand Down
Loading