mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(pre-push-safety-net): key evidence reuse by compatible semantics
This commit is contained in:
@@ -69,3 +69,37 @@ No graph node mutation, executable deserialization or repository writes are intr
|
|||||||
- CLAIM: Task 6.2 bounded local artifact publication and eviction.
|
- CLAIM: Task 6.2 bounded local artifact publication and eviction.
|
||||||
- EVIDENCE: nine cache contract tests and full suite above; production cache implementation, not a mocked storage path.
|
- EVIDENCE: nine cache contract tests and full suite above; production cache implementation, not a mocked storage path.
|
||||||
- GAPS: Semantic keys and live replay integration follow in 6.3 and 6.4; no warm-latency claim.
|
- GAPS: Semantic keys and live replay integration follow in 6.3 and 6.4; no warm-latency claim.
|
||||||
|
|
||||||
|
## Task 6.3 brief
|
||||||
|
|
||||||
|
Requirements 3.4, 4.3, 4.4, 6.4, 7.2 and 7.3; design: Cache and Engine Boundary
|
||||||
|
and Admission Policy. Query keys include graph, actual question parameters/context/depth,
|
||||||
|
facts/sanitizers, query versions and configuration. Comparison/final keys additionally bind
|
||||||
|
ranking, admission, optional model/prompt, eligibility and mode; comparisons include both
|
||||||
|
revisions and actual completed evidence. Ranking-only changes preserve query keys.
|
||||||
|
|
||||||
|
Comparison reuse is integrated into the existing policy entry point. Only complete, comparable
|
||||||
|
evidence can populate/look up comparisons. Unknown outcomes never qualify. Loaded CandidateDelta
|
||||||
|
contracts must match revision and semantics ownership. Current admission still runs afterwards;
|
||||||
|
the cache never confers eligibility. JSON decoding is non-executable and bounded to 32 MiB.
|
||||||
|
|
||||||
|
## Status Report
|
||||||
|
- STATUS: READY_FOR_REVIEW
|
||||||
|
- TASK: 6.3
|
||||||
|
- RED_PHASE_OUTPUT: `/tmp/group6-3-red.log`: two failing semantic-key tests with switch OFF; `/tmp/group6-3-policy-red.log`: missing policy cache integration failed. Enabled implementation passed; switch removed.
|
||||||
|
- TESTS_RUN: 71 focused policy/admission/cache tests passed; full suite 1260 passed, 9 skipped in 103.17s. Final ruff, formatting and mypy passed.
|
||||||
|
- EVIDENCE: real on-disk comparison hit returns identical supported delta; policy/revision changes miss; unanswered comparisons do not populate complete entries; exhaustive key-dimension and final-scope invalidation tests.
|
||||||
|
|
||||||
|
## Review Verdict
|
||||||
|
- VERDICT: APPROVED
|
||||||
|
- TASK: 6.3
|
||||||
|
- MECHANICAL_RESULTS: full regression suite, 71 focused tests, static checks and whitespace checks passed; no new placeholders or secrets.
|
||||||
|
- FINDINGS: cache does not store or replay push permission. Comparison data remains separate from current admission. Query reuse deliberately ignores ranking-only/mode changes, whereas comparison/final keys include them. Decoder and deadline checks were tightened during review.
|
||||||
|
- SUMMARY: Semantic identities preserve compatible evidence without transferring obsolete policy eligibility.
|
||||||
|
|
||||||
|
## Verification Result
|
||||||
|
- STATUS: VERIFIED
|
||||||
|
- CLAIM_TYPE: TASK
|
||||||
|
- CLAIM: Task 6.3 semantic keys and completed comparison evidence reuse.
|
||||||
|
- EVIDENCE: production policy integration and cache key tests, 1260 passing suite tests and final static checks.
|
||||||
|
- GAPS: Graph/query/preparation reuse must still be connected to replay under 6.4; no rollout claim.
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ hook latency is admitted.
|
|||||||
- _Boundary: Cache and Engine Boundary, push cache integration_
|
- _Boundary: Cache and Engine Boundary, push cache integration_
|
||||||
- _Depends: 6.1_
|
- _Depends: 6.1_
|
||||||
- _Requirements: 4.1, 4.3, 4.4, 7.3_
|
- _Requirements: 4.1, 4.3, 4.4, 7.3_
|
||||||
- [ ] 6.3 Key query and comparison results by all relevant semantics
|
- [x] 6.3 Key query and comparison results by all relevant semantics
|
||||||
- Include graph identity, question/context/depth, facts/sanitizers/query versions and configuration; include ranking/admission/model provenance for final results and both revision identities for comparisons.
|
- Include graph identity, question/context/depth, facts/sanitizers/query versions and configuration; include ranking/admission/model provenance for final results and both revision identities for comparisons.
|
||||||
- Keep incomplete query/comparison results out of complete lookups; preserve current eligibility checks after reuse.
|
- Keep incomplete query/comparison results out of complete lookups; preserve current eligibility checks after reuse.
|
||||||
- Done when source, context, fact, query, mode, policy and optional model changes invalidate the appropriate layer, while an identical compatible request returns the same evidence and provenance.
|
- Done when source, context, fact, query, mode, policy and optional model changes invalidate the appropriate layer, while an identical compatible request returns the same evidence and provenance.
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from openultrasast.contracts import Contract
|
||||||
from openultrasast.cpg.artifact import digest_value, graph_bytes, read_bytes
|
from openultrasast.cpg.artifact import digest_value, graph_bytes, read_bytes
|
||||||
from openultrasast.cpg.backend import _DeadlineExpired, _remove_owned_tree
|
from openultrasast.cpg.backend import _DeadlineExpired, _remove_owned_tree
|
||||||
from openultrasast.model.contracts import ExecutionBudget
|
from openultrasast.model.contracts import ExecutionBudget
|
||||||
@@ -123,6 +124,24 @@ class ArtifactCache:
|
|||||||
self.hits += 1
|
self.hits += 1
|
||||||
yield entry
|
yield entry
|
||||||
|
|
||||||
|
def get_json(self, key: str, *, kind: str, budget: ExecutionBudget) -> Any:
|
||||||
|
with self.lookup(key, budget) as entry:
|
||||||
|
if entry is None or entry.metadata != {"kind": kind} or entry.path.stat().st_size > 32 * 1024**2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(read_bytes(entry.path, budget.deadline_monotonic))
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def put_json(self, key: str, payload: object, *, kind: str, budget: ExecutionBudget, complete: bool) -> bool:
|
||||||
|
if not complete or time.monotonic() >= budget.deadline_monotonic:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload, sort_keys=True, allow_nan=False).encode()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return self.publish(key, data, {"kind": kind}, budget, complete=complete)
|
||||||
|
|
||||||
def _remove(self, root: Path, budget: ExecutionBudget) -> None:
|
def _remove(self, root: Path, budget: ExecutionBudget) -> None:
|
||||||
_remove_owned_tree(root, budget.deadline_monotonic)
|
_remove_owned_tree(root, budget.deadline_monotonic)
|
||||||
|
|
||||||
@@ -215,3 +234,37 @@ class ArtifactCache:
|
|||||||
_remove_owned_tree(pending, budget.deadline_monotonic + budget.cancellation_allowance_seconds)
|
_remove_owned_tree(pending, budget.deadline_monotonic + budget.cancellation_allowance_seconds)
|
||||||
except (OSError, _DeadlineExpired):
|
except (OSError, _DeadlineExpired):
|
||||||
self.last_reason = "cache_partial_cleanup_incomplete"
|
self.last_reason = "cache_partial_cleanup_incomplete"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SemanticKeys(Contract):
|
||||||
|
facts: str
|
||||||
|
queries: str
|
||||||
|
configuration: str
|
||||||
|
ranking: str
|
||||||
|
admission: str
|
||||||
|
model: str
|
||||||
|
eligibility: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
def query(self, *, graph: str, kind: str, request: object, context: object) -> str:
|
||||||
|
return digest_value(
|
||||||
|
{
|
||||||
|
"layer": "query-v1",
|
||||||
|
"graph": graph,
|
||||||
|
"kind": kind,
|
||||||
|
"request": request,
|
||||||
|
"context": context,
|
||||||
|
"facts": self.facts,
|
||||||
|
"queries": self.queries,
|
||||||
|
"configuration": self.configuration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def comparison(self, *, base: str, head: str, context: object, evidence: object) -> str:
|
||||||
|
return digest_value(
|
||||||
|
{"layer": "comparison-v1", "base": base, "head": head, "context": context, "evidence": evidence, "semantics": asdict(self)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def result(self, *, comparisons: object, scope: object) -> str:
|
||||||
|
return digest_value({"layer": "result-v1", "comparisons": comparisons, "scope": scope, "semantics": asdict(self)})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import asdict, dataclass, replace
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -20,6 +20,7 @@ from openultrasast.model.pipeline import ModelFinding, _arbitrate_all
|
|||||||
from openultrasast.model.regions import ScanRegion
|
from openultrasast.model.regions import ScanRegion
|
||||||
from openultrasast.model.scan import ModelScanResult, ScanBudget, _prefetched, _spec_for, scan_repository
|
from openultrasast.model.scan import ModelScanResult, ScanBudget, _prefetched, _spec_for, scan_repository
|
||||||
from openultrasast.model.specs import ConfigSpec, DominanceSpec, TaintSpec
|
from openultrasast.model.specs import ConfigSpec, DominanceSpec, TaintSpec
|
||||||
|
from openultrasast.push.cache import ArtifactCache, SemanticKeys
|
||||||
from openultrasast.push.contracts import ComparisonAnalysis, PushComparison, PushResult
|
from openultrasast.push.contracts import ComparisonAnalysis, PushComparison, PushResult
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +248,92 @@ def compare_evidence(
|
|||||||
head_semantics: str,
|
head_semantics: str,
|
||||||
base_semantics: str,
|
base_semantics: str,
|
||||||
execution_budget: ExecutionBudget | None = None,
|
execution_budget: ExecutionBudget | None = None,
|
||||||
|
cache: ArtifactCache | None = None,
|
||||||
|
cache_semantics: SemanticKeys | None = None,
|
||||||
|
) -> tuple[CandidateDelta, ...]:
|
||||||
|
"""Reuse completed comparison evidence; alert eligibility is always applied later."""
|
||||||
|
|
||||||
|
def complete(scan: ModelScanResult | None) -> bool:
|
||||||
|
return bool(
|
||||||
|
scan
|
||||||
|
and scan.scope
|
||||||
|
and scan.scope.population_complete
|
||||||
|
and not scan.degradations
|
||||||
|
and not scan.scope.unresolved_boundaries
|
||||||
|
and all(item.reason == "tier_zero" for item in scan.scope.deferred)
|
||||||
|
and all(item.status == "completed" for item in scan.question_outcomes)
|
||||||
|
and {item.identity for item in scan.scope.selected} == {item.identity for item in scan.question_outcomes}
|
||||||
|
)
|
||||||
|
|
||||||
|
key = None
|
||||||
|
if (
|
||||||
|
cache is not None
|
||||||
|
and cache_semantics is not None
|
||||||
|
and execution_budget is not None
|
||||||
|
and time.monotonic() < execution_budget.deadline_monotonic
|
||||||
|
and context.base_revision is not None
|
||||||
|
and not context.unresolved_boundaries
|
||||||
|
and head_semantics == base_semantics
|
||||||
|
and complete(head)
|
||||||
|
and complete(base)
|
||||||
|
):
|
||||||
|
from openultrasast.cpg.artifact import read_bytes
|
||||||
|
|
||||||
|
def evidence(scan: ModelScanResult) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
name: value
|
||||||
|
for name, value in asdict(scan).items()
|
||||||
|
if name not in {"seconds", "build_seconds", "query_seconds", "query_seconds_by_kind"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert base is not None
|
||||||
|
try:
|
||||||
|
key = cache_semantics.comparison(
|
||||||
|
base=context.base_revision,
|
||||||
|
head=context.head_revision,
|
||||||
|
context=context.to_payload(),
|
||||||
|
evidence={
|
||||||
|
"head": evidence(head),
|
||||||
|
"base": evidence(base),
|
||||||
|
"semantics": head_semantics,
|
||||||
|
"policy": hashlib.sha256(read_bytes(Path(__file__), execution_budget.deadline_monotonic)).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raw = cache.get_json(key, kind="comparison", budget=execution_budget)
|
||||||
|
if isinstance(raw, list):
|
||||||
|
decoded = tuple(CandidateDelta.from_payload(item) for item in raw)
|
||||||
|
if time.monotonic() < execution_budget.deadline_monotonic and all(
|
||||||
|
item.novelty != "unknown"
|
||||||
|
and item.base_revision == context.base_revision
|
||||||
|
and item.head_revision == context.head_revision
|
||||||
|
and item.semantics == head_semantics
|
||||||
|
for item in decoded
|
||||||
|
):
|
||||||
|
return decoded
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
key = None
|
||||||
|
result = _compare_evidence(
|
||||||
|
head, base, context=context, head_semantics=head_semantics, base_semantics=base_semantics, execution_budget=execution_budget
|
||||||
|
)
|
||||||
|
if key is not None and cache is not None and execution_budget is not None:
|
||||||
|
cache.put_json(
|
||||||
|
key,
|
||||||
|
[item.to_payload() for item in result],
|
||||||
|
kind="comparison",
|
||||||
|
budget=execution_budget,
|
||||||
|
complete=all(item.novelty != "unknown" for item in result),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_evidence(
|
||||||
|
head: ModelScanResult,
|
||||||
|
base: ModelScanResult | None,
|
||||||
|
*,
|
||||||
|
context: ChangeContext,
|
||||||
|
head_semantics: str,
|
||||||
|
base_semantics: str,
|
||||||
|
execution_budget: ExecutionBudget | None = None,
|
||||||
) -> tuple[CandidateDelta, ...]:
|
) -> tuple[CandidateDelta, ...]:
|
||||||
"""Absence counts only in completed comparable scope, never from scan silence."""
|
"""Absence counts only in completed comparable scope, never from scan silence."""
|
||||||
head_ops, base_ops = _operations(head, execution_budget), _operations(base, execution_budget) if base else ()
|
head_ops, base_ops = _operations(head, execution_budget), _operations(base, execution_budget) if base else ()
|
||||||
@@ -356,6 +443,8 @@ def compare_targeted_base(
|
|||||||
scan_budget: ScanBudget,
|
scan_budget: ScanBudget,
|
||||||
head_semantics: str,
|
head_semantics: str,
|
||||||
base_semantics: str,
|
base_semantics: str,
|
||||||
|
cache: ArtifactCache | None = None,
|
||||||
|
cache_semantics: SemanticKeys | None = None,
|
||||||
) -> DeltaComparison:
|
) -> DeltaComparison:
|
||||||
"""Re-run counterparts of the ranker's selected head questions through the existing driver.
|
"""Re-run counterparts of the ranker's selected head questions through the existing driver.
|
||||||
|
|
||||||
@@ -398,7 +487,14 @@ def compare_targeted_base(
|
|||||||
except Exception as error: # noqa: BLE001 -- comparison failure is coverage, not a new defect
|
except Exception as error: # noqa: BLE001 -- comparison failure is coverage, not a new defect
|
||||||
reasons.append(f"base_scan_failed:{type(error).__name__}")
|
reasons.append(f"base_scan_failed:{type(error).__name__}")
|
||||||
candidates = compare_evidence(
|
candidates = compare_evidence(
|
||||||
head, base, context=context, head_semantics=head_semantics, base_semantics=base_semantics, execution_budget=execution_budget
|
head,
|
||||||
|
base,
|
||||||
|
context=context,
|
||||||
|
head_semantics=head_semantics,
|
||||||
|
base_semantics=base_semantics,
|
||||||
|
execution_budget=execution_budget,
|
||||||
|
cache=cache,
|
||||||
|
cache_semantics=cache_semantics,
|
||||||
)
|
)
|
||||||
reasons.extend(c.reason for c in candidates if c.novelty == "unknown")
|
reasons.extend(c.reason for c in candidates if c.novelty == "unknown")
|
||||||
return DeltaComparison(candidates, base, head_semantics, tuple(dict.fromkeys(reasons)))
|
return DeltaComparison(candidates, base, head_semantics, tuple(dict.fromkeys(reasons)))
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Semantic layers invalidate independently and comparisons retain revision ownership."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from openultrasast.model.contracts import ExecutionBudget
|
||||||
|
from openultrasast.push.cache import ArtifactCache, SemanticKeys
|
||||||
|
|
||||||
|
|
||||||
|
def keys():
|
||||||
|
return SemanticKeys("facts", "queries", "configuration", "ranking", "admission", "model-disabled", "eligibility", "advisory")
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_and_result_invalidation_layers():
|
||||||
|
original = keys()
|
||||||
|
|
||||||
|
def query(k):
|
||||||
|
return k.query(graph="graph", kind="taint", request={"file": "a.js", "depth": 3}, context="context")
|
||||||
|
|
||||||
|
def result(k):
|
||||||
|
return k.comparison(base="base", head="head", context="context", evidence="evidence")
|
||||||
|
|
||||||
|
for field in ("facts", "queries", "configuration"):
|
||||||
|
changed = replace(original, **{field: "changed"})
|
||||||
|
assert query(changed) != query(original)
|
||||||
|
assert result(changed) != result(original)
|
||||||
|
for field in ("ranking", "admission", "model", "eligibility", "mode"):
|
||||||
|
changed = replace(original, **{field: "changed"})
|
||||||
|
assert query(changed) == query(original)
|
||||||
|
assert result(changed) != result(original)
|
||||||
|
for field in ("graph", "kind", "request", "context"):
|
||||||
|
args = {"graph": "graph", "kind": "taint", "request": {"file": "a.js", "depth": 3}, "context": "context"}
|
||||||
|
args[field] = "changed"
|
||||||
|
assert original.query(**args) != query(original)
|
||||||
|
for field in ("base", "head", "context", "evidence"):
|
||||||
|
args = {"base": "base", "head": "head", "context": "context", "evidence": "evidence"}
|
||||||
|
args[field] = "changed"
|
||||||
|
assert original.comparison(**args) != result(original)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_evidence_retains_provenance_and_rejects_incomplete(tmp_path):
|
||||||
|
cache = ArtifactCache(tmp_path / "cache", max_bytes=10000)
|
||||||
|
budget = ExecutionBudget(time.monotonic() + 5, 0.2)
|
||||||
|
key = keys().query(graph="graph", kind="taint", request={}, context="context")
|
||||||
|
payload = {"rows": [], "provenance": {"facts": "facts", "query": "queries"}}
|
||||||
|
assert not cache.put_json(key, payload, kind="query", budget=budget, complete=False)
|
||||||
|
assert cache.get_json(key, kind="query", budget=budget) is None
|
||||||
|
assert cache.put_json(key, payload, kind="query", budget=budget, complete=True)
|
||||||
|
assert cache.get_json(key, kind="query", budget=budget) == payload
|
||||||
|
assert cache.get_json(key, kind="comparison", budget=budget) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_results_bind_scope_and_current_admission_provenance():
|
||||||
|
original = keys()
|
||||||
|
|
||||||
|
def result(k, scope="scope"):
|
||||||
|
return k.result(comparisons=["base-head"], scope=scope)
|
||||||
|
|
||||||
|
for field in ("facts", "queries", "configuration", "ranking", "admission", "model", "eligibility", "mode"):
|
||||||
|
assert result(replace(original, **{field: "changed"})) != result(original)
|
||||||
|
assert result(original, "different-scope") != result(original)
|
||||||
|
assert original.result(comparisons=["another-base-head"], scope="scope") != result(original)
|
||||||
@@ -238,3 +238,30 @@ def test_proven_tier_zero_other_families_do_not_invalidate_base_answer():
|
|||||||
assert delta(scan(), base).novelty == "new"
|
assert delta(scan(), base).novelty == "new"
|
||||||
base = replace(base, scope=replace(base.scope, deferred=(DeferredQuestion(other, "budget_exhausted", ()),)))
|
base = replace(base, scope=replace(base.scope, deferred=(DeferredQuestion(other, "budget_exhausted", ()),)))
|
||||||
assert delta(scan(), base).novelty == "unknown"
|
assert delta(scan(), base).novelty == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_cache_reuses_only_completed_compatible_evidence(tmp_path, monkeypatch):
|
||||||
|
import openultrasast.push.policy as policy
|
||||||
|
from openultrasast.push.cache import ArtifactCache, SemanticKeys
|
||||||
|
|
||||||
|
cache = ArtifactCache(tmp_path / "cache", max_bytes=100000)
|
||||||
|
keys = SemanticKeys("facts", "queries", "config", "rank", "admission", "disabled", "empty", "advisory")
|
||||||
|
budget = ExecutionBudget(time.monotonic() + 10, 0.2)
|
||||||
|
args = dict(context=context(), head_semantics="same", base_semantics="same", execution_budget=budget, cache=cache, cache_semantics=keys)
|
||||||
|
expected = policy.compare_evidence(scan(), scan(line=5, sanitized=True, findings=False), **args)
|
||||||
|
assert expected[0].novelty == "worsened"
|
||||||
|
hits = cache.hits
|
||||||
|
assert policy.compare_evidence(scan(), scan(line=5, sanitized=True, findings=False), **args) == expected
|
||||||
|
assert cache.hits > hits
|
||||||
|
args["cache_semantics"] = replace(keys, admission="changed")
|
||||||
|
hits = cache.hits
|
||||||
|
policy.compare_evidence(scan(), scan(line=5, sanitized=True, findings=False), **args)
|
||||||
|
assert cache.hits == hits
|
||||||
|
args["context"] = replace(context(), base_revision="another-base")
|
||||||
|
changed = policy.compare_evidence(scan(), scan(line=5, sanitized=True, findings=False), **args)
|
||||||
|
assert changed[0].base_revision == "another-base"
|
||||||
|
incomplete = policy.compare_evidence(scan(), scan(status="unanswered"), **args)
|
||||||
|
assert incomplete[0].novelty == "unknown"
|
||||||
|
hits = cache.hits
|
||||||
|
policy.compare_evidence(scan(), scan(status="unanswered"), **args)
|
||||||
|
assert cache.hits == hits
|
||||||
|
|||||||
Reference in New Issue
Block a user