feat(detection-core): apply bounded model corroboration

This commit is contained in:
Marius Ciepluch
2026-10-31 16:57:11 +01:00
parent d28998b262
commit a1800c0c74
3 changed files with 516 additions and 4 deletions
+1 -1
View File
@@ -163,7 +163,7 @@
- _Requirements: 9.1, 9.2, 10.3_ - _Requirements: 9.1, 9.2, 10.3_
- _Boundary: Model Handoff_ - _Boundary: Model Handoff_
- _Depends: 2.4, 3.1_ - _Depends: 2.4, 3.1_
- [ ] 6.2 Apply model results without rewriting deterministic evidence - [x] 6.2 Apply model results without rewriting deterministic evidence
- Validate current prompt/example/fitted-model/mapping lineage against the evaluated population and declared artifacts. - Validate current prompt/example/fitted-model/mapping lineage against the evaluated population and declared artifacts.
- Reject proposer/adjudicator role collision and results for another packet, family, residual, or mapping. - Reject proposer/adjudicator role collision and results for another packet, family, residual, or mapping.
- Allow model-corroborated output only when deterministic residual prerequisites are complete. - Allow model-corroborated output only when deterministic residual prerequisites are complete.
+197
View File
@@ -0,0 +1,197 @@
"""Apply validated model results without mutating deterministic evidence."""
from __future__ import annotations
from openultrasast.core._model_common import ModelContractError
from openultrasast.core._model_result import ModelResult, ModelStatus
from openultrasast.core.canonical import ContentResolver
from openultrasast.core.custody import (
LineagePurpose,
PopulationCustodyError,
PopulationCustodyPolicy,
PopulationCustodyPort,
PopulationUseReceipt,
validate_population_custody_log,
)
from openultrasast.core.evidence import (
Decision,
EvidenceBasis,
EvidenceRecord,
NeutralOutcome,
derive_packet_digest,
)
from openultrasast.core.family import FamilyContract, family_contract_digest, validate_record
from openultrasast.core.lineage import LineageExpectation, LineageValidationError, validate_lineage
from openultrasast.core.model import ModelProgramDeclaration, ModelRequest, validate_model_result
from openultrasast.core.model import completeness_snapshot as _completeness_snapshot
from openultrasast.core.provenance import DecisionProvenance
from openultrasast.core.semantics import RelationStatus
def _expectations(program: ModelProgramDeclaration) -> tuple[LineageExpectation, ...]:
expected = [
LineageExpectation(LineagePurpose.PROMPT, program.prompt_digest, 1, 1),
LineageExpectation(LineagePurpose.DECISION_MAPPING, program.decision_mapping_digest, 1, 1),
]
if program.example_set_digest is not None:
expected.append(LineageExpectation(LineagePurpose.EXAMPLE, program.example_set_digest, 1, 1))
if program.fitted_component_digest is not None:
expected.append(LineageExpectation(LineagePurpose.FITTED_MODEL, program.fitted_component_digest, 1, 1))
return tuple(expected)
def _expected_artifacts(program: ModelProgramDeclaration) -> set[tuple[LineagePurpose, str]]:
return {(item.purpose, item.influenced_artifact_digest) for item in _expectations(program)}
def _authoritative_receipts(
request: ModelRequest,
protected_population_digest: str,
custody: PopulationCustodyPort,
policy: PopulationCustodyPolicy,
) -> tuple[PopulationUseReceipt, ...]:
populations = {protected_population_digest, *(use.source_population_digest for use in request.lineage.uses)}
expected = _expected_artifacts(request.program)
receipts: list[PopulationUseReceipt] = []
for population_digest in sorted(populations):
log = custody.current_log(population_digest)
validate_population_custody_log(log, policy, current=custody)
receipts.extend(
receipt
for receipt in log.receipts
if (receipt.request.purpose, receipt.request.influenced_artifact_digest) in expected
and (receipt.request.arm_id == request.lineage.arm_id or receipt.request.labels_accessed)
)
return tuple(receipts)
def _refuse_omitted_uses(
request: ModelRequest,
receipts: tuple[PopulationUseReceipt, ...],
protected_population_digest: str,
) -> None:
declared = {use.population_use_receipt_digest for use in request.lineage.uses}
for receipt in receipts:
if receipt.receipt_digest in declared:
continue
population = receipt.request.population_digest
if receipt.request.labels_accessed:
reason = f"label_access_forbidden:{population}"
if population == protected_population_digest:
reason += f",protected_population_labels:{population}"
raise ModelContractError(f"model lineage is contaminated: {reason}")
raise ModelContractError(f"model lineage omitted current use: {receipt.receipt_digest}")
def _validate_program_lineage(
request: ModelRequest,
record: EvidenceRecord,
custody: PopulationCustodyPort,
policy: PopulationCustodyPolicy,
) -> None:
protected = record.packet.provenance.population_digest
try:
decision = validate_lineage(
request.lineage,
request.program.input_bundle_digest,
_expectations(request.program),
protected,
True,
custody,
policy,
)
receipts = _authoritative_receipts(request, protected, custody, policy)
except (LineageValidationError, PopulationCustodyError, TypeError, ValueError) as exc:
raise ModelContractError(f"model lineage cannot be validated: {exc}") from exc
if not decision.allowed:
raise ModelContractError("model lineage is not allowed: " + ",".join(decision.reasons))
_refuse_omitted_uses(request, receipts, protected)
def _validate_application_inputs(
record: EvidenceRecord,
contract: FamilyContract,
request: ModelRequest,
result: ModelResult,
resolver: ContentResolver,
custody: PopulationCustodyPort,
custody_policy: PopulationCustodyPolicy,
) -> None:
if not isinstance(record, EvidenceRecord) or not isinstance(contract, FamilyContract):
raise ModelContractError("record and contract must use detection-core contract types")
if record != request.record or contract != request.contract:
raise ModelContractError("record or family contract does not match the frozen model request")
if family_contract_digest(contract) != request.family_contract_digest:
raise ModelContractError("family contract identity does not match the frozen model request")
if request.custody_policy_digest != custody_policy.policy_digest:
raise ModelContractError("custody policy does not match the frozen model request")
if request.program.program_digest in request.proposal_program_digests:
raise ModelContractError("one program cannot propose and adjudicate the same evidence path")
try:
validate_record(contract, record)
validate_model_result(request, result, resolver)
except (TypeError, ValueError) as exc:
raise ModelContractError(f"model application input is invalid: {exc}") from exc
_validate_program_lineage(request, record, custody, custody_policy)
def _residual_complete(request: ModelRequest) -> bool:
statuses = {item.role: item.status for item in _completeness_snapshot(request.record, request.contract)}
return all(statuses.get(role) is RelationStatus.COMPLETE for role in request.residual_question.required_roles)
def _derived_record(record: EvidenceRecord, contract: FamilyContract, request: ModelRequest, result: ModelResult) -> EvidenceRecord:
if result.family_decision is None:
raise ModelContractError("successful model result requires a family decision")
rules = tuple(item for item in contract.decisions if item.family_code == result.family_decision)
if len(rules) != 1:
raise ModelContractError("model family decision does not resolve exactly once")
rule = rules[0]
if rule.outcome is NeutralOutcome.INDETERMINATE:
raise ModelContractError("model output cannot synthesize an indeterminate reason")
provenance = DecisionProvenance(
result.identity,
result.result_digest,
request.program.declaration_digest,
None,
None,
request.lineage.manifest_digest,
)
decision = Decision(
result.family_decision,
rule.outcome,
EvidenceBasis.MODEL_CORROBORATED,
record.decision.bounded_scope,
(),
derive_packet_digest(record.packet),
provenance,
)
derived = EvidenceRecord(record.packet, decision, "")
try:
validate_record(contract, derived)
except (TypeError, ValueError) as exc:
raise ModelContractError(f"model-derived record is invalid: {exc}") from exc
return derived
def apply_model_result(
record: EvidenceRecord,
contract: FamilyContract,
request: ModelRequest,
result: ModelResult,
resolver: ContentResolver,
custody: PopulationCustodyPort,
custody_policy: PopulationCustodyPolicy,
) -> EvidenceRecord:
"""Return a corroborated decision over the exact immutable deterministic packet."""
_validate_application_inputs(record, contract, request, result, resolver, custody, custody_policy)
if result.status is ModelStatus.FAILED:
return record
if not _residual_complete(request):
if record.decision.outcome is not NeutralOutcome.INDETERMINATE:
raise ModelContractError("incomplete model prerequisites require an indeterminate deterministic record")
return record
return _derived_record(record, contract, request, result)
__all__ = ["apply_model_result"]
+318 -3
View File
@@ -3,12 +3,17 @@
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass, replace
import pytest import pytest
from detection_core_fixtures import InMemoryContentResolver, stored_artifact from detection_core_fixtures import (
InMemoryContentResolver,
InMemoryPopulationCustody,
custody_contract,
stored_artifact,
)
from openultrasast.core import canonical, evidence, family, model, provenance, semantics from openultrasast.core import canonical, evidence, family, model, model_apply, provenance, semantics
def _digest(value: str) -> str: def _digest(value: str) -> str:
@@ -326,3 +331,313 @@ def test_core_model_contract_has_no_runtime_dependency_imports() -> None:
assert "import dspy" not in source assert "import dspy" not in source
assert "litellm" not in source assert "litellm" not in source
assert "openai" not in source assert "openai" not in source
@dataclass(frozen=True)
class ApplicationCase:
contract: family.FamilyContract
record: evidence.EvidenceRecord
request: model.ModelRequest
result: model.ModelResult
resolver: InMemoryContentResolver
custody: InMemoryPopulationCustody
custody_policy: provenance.PopulationCustodyPolicy
custodian: str
def _append_model_use(
custody: InMemoryPopulationCustody,
policy: provenance.PopulationCustodyPolicy,
custodian: str,
*,
arm_id: str,
purpose: provenance.LineagePurpose,
artifact_digest: str,
labels_accessed: bool = False,
) -> provenance.LineageUse:
population = custody.current_log(_digest("population"))
request = provenance.PopulationUseRequest(
population.population_digest,
arm_id,
purpose,
labels_accessed,
artifact_digest,
population.log_digest,
)
receipt = custody.append(request, custodian)
provenance.validate_population_custody_log(custody.current_log(population.population_digest), policy)
return provenance.LineageUse(
purpose,
population.population_digest,
labels_accessed,
artifact_digest,
receipt.receipt_digest,
)
def _application_case(
*,
contract: family.FamilyContract | None = None,
record: evidence.EvidenceRecord | None = None,
failed: bool = False,
) -> ApplicationCase:
contract = contract or _contract()
record = record or _record(contract)
question = contract.residual_questions[0]
completeness = model.completeness_snapshot(record, contract)
bundle = model.derive_model_input_bundle_digest(record, contract, question, completeness)
initial, policy, custodian = custody_contract()
custody = InMemoryPopulationCustody(initial, policy)
artifacts = (
(provenance.LineagePurpose.PROMPT, _digest("prompt")),
(provenance.LineagePurpose.EXAMPLE, _digest("examples")),
(provenance.LineagePurpose.FITTED_MODEL, _digest("fitted")),
(provenance.LineagePurpose.DECISION_MAPPING, _digest("mapping")),
)
uses = tuple(
_append_model_use(
custody,
policy,
custodian,
arm_id="model-arm",
purpose=purpose,
artifact_digest=artifact,
)
for purpose, artifact in artifacts
)
lineage = provenance.LineageManifest(
"model-arm",
bundle,
uses,
provenance.derive_lineage_manifest_digest("model-arm", bundle, uses),
)
program = model.make_model_program_declaration(
family_contract_digest=family.family_contract_digest(contract),
provider="provider",
model_name="model-v1",
program_digest=_digest("program"),
prompt_digest=_digest("prompt"),
example_set_digest=_digest("examples"),
fitted_component_digest=_digest("fitted"),
configuration_digest=_digest("model-configuration"),
adapter_revision="adapter-v1",
decision_mapping_digest=_digest("mapping"),
input_bundle_digest=bundle,
lineage_manifest_digest=lineage.manifest_digest,
)
request = model.make_model_request(
record=record,
contract=contract,
residual_question_id=question.question_id,
program=program,
lineage=lineage,
custody_policy_digest=policy.policy_digest,
proposal_program_digests=(_digest("proposer"),),
trusted_response_store_identities=("trusted-store",),
maximum_response_bytes=256,
)
response = model.make_preserved_model_response(
normalized_fields=(semantics.FieldValue("decision", semantics.FieldKind.TEXT, "unsafe"),),
inline_redacted_text="[redacted] unsafe",
artifact=None,
redaction_policy_digest=_digest("redaction-policy"),
)
result = model.make_model_result(
request=request,
status=model.ModelStatus.FAILED if failed else model.ModelStatus.SUCCEEDED,
response=response,
family_decision=None if failed else "unsafe",
failure_reason="provider_timeout" if failed else None,
)
return ApplicationCase(contract, record, request, result, InMemoryContentResolver({}), custody, policy, custodian)
def test_apply_model_result_preserves_packet_and_adds_only_model_corroboration() -> None:
case = _application_case()
packet_digest = evidence.derive_packet_digest(case.record.packet)
derived = model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
assert isinstance(derived, evidence.EvidenceRecord)
assert derived.packet is case.record.packet
assert evidence.derive_packet_digest(derived.packet) == packet_digest
assert derived.decision.basis is evidence.EvidenceBasis.MODEL_CORROBORATED
assert derived.decision.family_code == "unsafe"
assert derived.decision.provenance.model_result_digest == case.result.result_digest
assert derived.decision.provenance.proof_record_digest is None
def test_failed_model_result_preserves_the_deterministic_record() -> None:
case = _application_case(failed=True)
derived = model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
assert derived is case.record
def _incomplete_application_case() -> ApplicationCase:
base_contract = _contract()
unknown = family.DecisionRule(
"unknown",
evidence.NeutralOutcome.INDETERMINATE,
("flow",),
(semantics.RelationStatus.INCOMPLETE,),
)
contract = replace(base_contract, decisions=(*base_contract.decisions, unknown))
complete = _record(contract)
relation = complete.packet.transitions[0]
incomplete_relation = semantics.make_relation(
kind=relation.kind,
role=relation.role,
source=relation.source,
target=relation.target,
location=relation.location,
status=semantics.RelationStatus.INCOMPLETE,
conditions=relation.conditions,
reasons=("missing",),
provenance=relation.provenance,
)
packet = replace(complete.packet, transitions=(incomplete_relation,), completeness_reasons=("missing",))
decision = evidence.Decision(
"unknown",
evidence.NeutralOutcome.INDETERMINATE,
evidence.EvidenceBasis.DETERMINISTIC,
packet.scope,
("missing",),
evidence.derive_packet_digest(packet),
provenance.DecisionProvenance(None, None, None, None, None, None),
)
return _application_case(contract=contract, record=evidence.EvidenceRecord(packet, decision, ""))
def test_incomplete_residual_prerequisite_remains_indeterminate() -> None:
case = _incomplete_application_case()
derived = model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
assert derived is case.record
assert derived.decision.outcome is evidence.NeutralOutcome.INDETERMINATE
assert derived.decision.basis is evidence.EvidenceBasis.DETERMINISTIC
def test_model_application_refuses_proposer_adjudicator_collision_and_family_substitution() -> None:
case = _application_case()
object.__setattr__(case.request, "proposal_program_digests", (case.request.program.program_digest,))
with pytest.raises(model.ModelContractError, match="propose|adjudicat"):
model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
case = _application_case()
other_contract = replace(case.contract, family_id="other-family")
with pytest.raises(model.ModelContractError, match="family|contract"):
model_apply.apply_model_result(
case.record,
other_contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
def test_model_application_refuses_stale_or_label_contaminated_lineage() -> None:
case = _application_case()
_append_model_use(
case.custody,
case.custody_policy,
case.custodian,
arm_id="model-arm",
purpose=provenance.LineagePurpose.PROMPT,
artifact_digest=case.request.program.prompt_digest,
)
with pytest.raises(model.ModelContractError, match="omitted|current"):
model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
case = _application_case()
_append_model_use(
case.custody,
case.custody_policy,
case.custodian,
arm_id="other-arm",
purpose=provenance.LineagePurpose.DECISION_MAPPING,
artifact_digest=case.request.program.decision_mapping_digest,
labels_accessed=True,
)
with pytest.raises(model.ModelContractError, match="label|contaminat|spent"):
model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)
def test_model_application_refuses_custody_policy_and_result_substitution() -> None:
case = _application_case()
other_policy = replace(case.custody_policy, policy_digest=_digest("other-policy"))
with pytest.raises(model.ModelContractError, match="custody policy"):
model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
other_policy,
)
case = _application_case()
object.__setattr__(case.result, "packet_digest", _digest("other-packet"))
with pytest.raises(model.ModelContractError, match="packet"):
model_apply.apply_model_result(
case.record,
case.contract,
case.request,
case.result,
case.resolver,
case.custody,
case.custody_policy,
)