modified: .gitlab-ci.yml modified: bench/qa_questions.txt modified: bench/qa_sweep.py modified: bench/run.sh modified: docs/TICKETS.md modified: docs/_source/README.md modified: docs/_source/_ext/makefile_targets.py modified: docs/_source/api/cli.rst modified: docs/_source/api/distill.rst modified: docs/_source/api/mesh.rst modified: docs/_source/api/qa.rst modified: docs/_source/api/retrieval.rst modified: docs/_source/api/storage.rst modified: docs/_source/api/substrate.rst modified: docs/_source/concepts.rst modified: docs/_source/conf.py modified: docs/_source/cookbook.rst modified: docs/_source/index.rst modified: docs/_source/license.rst modified: docs/_source/quickstart.rst modified: docs/bench-maxing.md modified: docs/benchmarks.md modified: docs/cti-architecture.md modified: docs/diagrams/aborist-modules.dot modified: docs/diagrams/aborist-modules.svg modified: docs/diagrams/mesh-data-flow.dot modified: docs/diagrams/mesh-epoch-lifecycle.dot modified: docs/diagrams/mesh-epoch-lifecycle.svg modified: docs/diagrams/mesh-group-decisions.dot modified: docs/diagrams/mesh-group-decisions.svg modified: docs/diagrams/mesh-identity-stack.dot modified: docs/diagrams/mesh-secret-envelope.dot modified: docs/mesh.md modified: docs/qa-modes-bench.md modified: docs/seven-point-program.md modified: docs/tickets/ticket-000001-retrieval-keywords-audit-gap.md modified: docs/tickets/ticket-000002-reference-frame-polarity-contract.md modified: docs/tickets/ticket-000003-anchor-class-warrant.md modified: docs/tickets/ticket-000005-label-ladder-migration.md modified: docs/tickets/ticket-000006-bench-emergent-findings.md modified: docs/tickets/ticket-000007-query-layer-hyphen-fold.md modified: docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md modified: docs/tickets/ticket-000009-quantifier-preflight-dag-binding.md modified: docs/tickets/ticket-000010-metacognition-preflight-guard.md modified: docs/tickets/ticket-000011-soft-preflight-hint-sidecar.md modified: scripts/backfill_concepts.py modified: scripts/bench_emergent.py modified: tests/crawler/test_async_web_fetcher.py modified: tests/crawler/test_bridge.py modified: tests/crawler/test_web_fetch.py modified: tests/test_bench_qa_sweep.py modified: tests/test_burn.py modified: tests/test_burn_doc.py modified: tests/test_claim_lattice.py modified: tests/test_cli_render.py modified: tests/test_compress.py modified: tests/test_concepts.py modified: tests/test_dag.py modified: tests/test_directives.py modified: tests/test_distill.py modified: tests/test_distill_recursive.py modified: tests/test_evict.py modified: tests/test_frame.py modified: tests/test_grok_source.py modified: tests/test_html_source.py modified: tests/test_ingest.py modified: tests/test_inspect.py modified: tests/test_journal.py modified: tests/test_keys.py modified: tests/test_llm_context_base.py modified: tests/test_merkle.py modified: tests/test_mesh.py modified: tests/test_mesh_aead.py modified: tests/test_mesh_chain.py modified: tests/test_mesh_cli.py modified: tests/test_mesh_cli_pull.py modified: tests/test_mesh_wire.py modified: tests/test_mesh_wire_e2e.py modified: tests/test_metacognition.py modified: tests/test_migration_audit_mode.py modified: tests/test_providence_source.py modified: tests/test_qa.py modified: tests/test_qa_quality_live.py modified: tests/test_quantifier_caps.py modified: tests/test_quantifier_classifier.py modified: tests/test_quantifier_phase4.py modified: tests/test_quantifier_reminder.py modified: tests/test_query.py modified: tests/test_reclassify.py modified: tests/test_repair.py modified: tests/test_resume.py modified: tests/test_snapshot.py modified: tests/test_soft_preflight.py modified: tests/test_tfidf.py modified: tests/test_vcs_source.py modified: tests/test_verify.py modified: tests/test_verify_json.py modified: tests/test_versioned_ingest.py modified: tests/test_warrant.py modified: tests/test_wikipedia_old.py modified: tests/test_wikipedia_xml.py modified: tests/test_wikitext.py
474 lines
17 KiB
Python
474 lines
17 KiB
Python
"""Per-run Merkle-DAG provenance.
|
|
|
|
`build_run_dag` produces a deterministic Merkle root over the seven
|
|
stages of a query/ask call. `verify_run_dag` recomputes the root from
|
|
the persisted node list and confirms it matches.
|
|
|
|
The DAG is not part of cache_key (cache inputs determine the answer;
|
|
the answer determines the DAG — folding it back would create a cycle).
|
|
It rides alongside the providence record as `run_dag_root` /
|
|
`run_dag_blob` so an auditor can verify the run was constructed
|
|
exactly as recorded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from arborist.qa.dag import (
|
|
build_run_dag,
|
|
localize_failure,
|
|
preflight_node_hash,
|
|
verify_run_dag,
|
|
)
|
|
|
|
|
|
def _kw(**overrides):
|
|
base = dict(
|
|
question_hash="a" * 64,
|
|
sources=[
|
|
{
|
|
"document_root": "b" * 64,
|
|
"source_role": "primary_answer_source",
|
|
"score": 1.0,
|
|
"chunk_idx": 0,
|
|
}
|
|
],
|
|
context_root="b" * 64,
|
|
conversation_hash="c" * 64,
|
|
answer_text="The cat is on the mat.",
|
|
audit_mode="STRICT",
|
|
verifier_method="quote",
|
|
n_quotes=1,
|
|
n_verified=1,
|
|
claim_statuses=[
|
|
{"text": "the cat", "status": "VERIFIED_QUOTE", "method": "quote"}
|
|
],
|
|
lookup_path="miss",
|
|
)
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_dag_root_is_deterministic():
|
|
"""Same inputs → same root, byte-for-byte."""
|
|
a = build_run_dag(**_kw())
|
|
b = build_run_dag(**_kw())
|
|
assert a["root"] == b["root"]
|
|
assert len(a["nodes"]) == 7
|
|
|
|
|
|
def test_dag_root_changes_when_answer_changes():
|
|
a = build_run_dag(**_kw())
|
|
b = build_run_dag(**_kw(answer_text="Different answer."))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_root_changes_when_audit_mode_changes():
|
|
a = build_run_dag(**_kw())
|
|
b = build_run_dag(**_kw(audit_mode="UNGROUNDED"))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_root_changes_when_verifier_method_changes():
|
|
a = build_run_dag(**_kw())
|
|
b = build_run_dag(**_kw(verifier_method="paraphrase"))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_root_changes_when_question_hash_changes():
|
|
a = build_run_dag(**_kw())
|
|
b = build_run_dag(**_kw(question_hash="d" * 64))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_root_changes_when_sources_change():
|
|
a = build_run_dag(**_kw())
|
|
new_sources = [{"document_root": "e" * 64, "source_role": "primary_answer_source",
|
|
"score": 1.0, "chunk_idx": 0}]
|
|
b = build_run_dag(**_kw(sources=new_sources))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_seven_stages_in_order():
|
|
"""The seven stages are emitted in fixed order so the root is
|
|
canonical: question / retrieval / context / prompt / answer /
|
|
verify / final_label."""
|
|
out = build_run_dag(**_kw())
|
|
stages = [n["stage"] for n in out["nodes"]]
|
|
assert stages == [
|
|
"question", "retrieval", "context", "prompt",
|
|
"answer", "verify", "final_label",
|
|
]
|
|
|
|
|
|
def test_verify_dag_round_trip():
|
|
"""Recomputing the root from the persisted nodes must match."""
|
|
out = build_run_dag(**_kw())
|
|
assert verify_run_dag(out) is True
|
|
|
|
|
|
def test_verify_dag_detects_node_tampering():
|
|
"""Mutating a stage hash makes verify fail."""
|
|
out = build_run_dag(**_kw())
|
|
out["nodes"][3]["hash"] = "f" * 64 # tamper with prompt node
|
|
# Root no longer matches the (mutated) leaves.
|
|
assert verify_run_dag(out) is False
|
|
|
|
|
|
def test_localize_failure_strict_returns_none():
|
|
"""STRICT verdict has no failure to localize."""
|
|
assert localize_failure(audit_mode="STRICT", n_sources=3, n_quotes=4, n_verified=4) is None
|
|
|
|
|
|
def test_localize_failure_no_sources_is_retrieval():
|
|
"""No admitted sources → retrieval-stage failure (gate over-rejected
|
|
or corpus genuinely lacks the topic)."""
|
|
assert localize_failure(
|
|
audit_mode="UNGROUNDED", n_sources=0, n_quotes=0, n_verified=0
|
|
) == "retrieval"
|
|
|
|
|
|
def test_localize_failure_no_quotes_is_context():
|
|
"""Sources retrieved but no quotes extracted → context-stage failure
|
|
(per-source cap dropped relevant content, or model declined to
|
|
cite). Distinct from answer-stage failures where quotes existed but
|
|
didn't verify."""
|
|
assert localize_failure(
|
|
audit_mode="UNGROUNDED", n_sources=5, n_quotes=0, n_verified=0
|
|
) == "context"
|
|
|
|
|
|
def test_localize_failure_unverified_quotes_is_answer():
|
|
"""Quotes extracted but not all verify → answer-stage failure
|
|
(model fabricated, paraphrased inside quotes, or appended
|
|
citations). The case mechanical_repair targets."""
|
|
assert localize_failure(
|
|
audit_mode="HYBRID", n_sources=3, n_quotes=4, n_verified=3
|
|
) == "answer"
|
|
assert localize_failure(
|
|
audit_mode="UNGROUNDED", n_sources=3, n_quotes=2, n_verified=0
|
|
) == "answer"
|
|
|
|
|
|
def test_localize_failure_lands_on_run_dag_verify_node():
|
|
"""failure_stage is folded into the verify node's payload so
|
|
auditors reading the persisted DAG can see which stage produced
|
|
a non-STRICT verdict."""
|
|
out = build_run_dag(**_kw(
|
|
audit_mode="HYBRID",
|
|
n_quotes=4,
|
|
n_verified=3,
|
|
))
|
|
# The verify_node hash should differ between failure_stage='answer'
|
|
# and a STRICT verdict — failure_stage enters the hashed payload.
|
|
out_strict = build_run_dag(**_kw(
|
|
audit_mode="STRICT",
|
|
n_quotes=4,
|
|
n_verified=4,
|
|
))
|
|
out["root"] != out_strict["root"] # at minimum, audit_mode differs
|
|
|
|
|
|
def test_verify_dag_accepts_json_string():
|
|
"""Sidecar/audit tools persist the DAG as JSON; verify accepts both."""
|
|
import json
|
|
out = build_run_dag(**_kw())
|
|
blob = json.dumps(out, separators=(",", ":"))
|
|
assert verify_run_dag(blob) is True
|
|
|
|
|
|
# ---------------------------------------------------------------- Ticket #000009: preflight stage
|
|
|
|
def test_preflight_node_hash_is_deterministic():
|
|
"""Same nested-clause inputs → same hex string, byte-for-byte."""
|
|
qs = {"logical_statuses": ["well_formed"], "preflight_result": "PREFLIGHT_OK"}
|
|
quant = {"intensity": "SINGULAR", "is_broad": False}
|
|
answer_contract = {"guard_enabled": True, "apply_caps_active": False}
|
|
a = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
answer_contract=answer_contract,
|
|
)
|
|
b = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
answer_contract=answer_contract,
|
|
)
|
|
assert a == b
|
|
assert len(a) == 64 # SHA-256 hex
|
|
|
|
|
|
def test_preflight_node_hash_changes_with_question_state():
|
|
base_quant = {"intensity": "SINGULAR", "is_broad": False}
|
|
answer_contract = {"guard_enabled": True}
|
|
h_a = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier=base_quant, answer_contract=answer_contract,
|
|
)
|
|
h_b = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_PARTIAL"},
|
|
quantifier=base_quant, answer_contract=answer_contract,
|
|
)
|
|
assert h_a != h_b
|
|
|
|
|
|
def test_preflight_node_hash_changes_with_answer_contract():
|
|
"""Apply-caps flip in answer_contract MUST bump the node hash
|
|
so audit replay can distinguish guard-on vs guard-off rows
|
|
that otherwise share the same classifier output."""
|
|
qs = {"logical_statuses": ["broad_quantifier_unbounded"]}
|
|
quant = {"intensity": "ALL", "is_broad": True}
|
|
h_off = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
answer_contract={"apply_caps_active": False},
|
|
)
|
|
h_on = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
answer_contract={"apply_caps_active": True},
|
|
)
|
|
assert h_off != h_on
|
|
|
|
|
|
def test_preflight_node_hash_changes_with_prompt_contract():
|
|
"""Reminder-injection flip MUST bump the node hash."""
|
|
qs = {"logical_statuses": ["broad_quantifier_unbounded"]}
|
|
quant = {"intensity": "ALL", "is_broad": True}
|
|
h_off = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
prompt_contract={"reminder_injected": False},
|
|
)
|
|
h_on = preflight_node_hash(
|
|
question_state=qs, quantifier=quant,
|
|
prompt_contract={
|
|
"reminder_injected": True,
|
|
"reminder_template_id": "broad-quantifier-unbounded-v1",
|
|
},
|
|
)
|
|
assert h_off != h_on
|
|
|
|
|
|
def test_preflight_node_hash_changes_with_policy_refs():
|
|
"""policy_refs.governance_policy_hash flip → distinct node
|
|
hash. Required for cache-key/run-DAG-root joint invalidation."""
|
|
qs = {"logical_statuses": ["well_formed"]}
|
|
h_a = preflight_node_hash(
|
|
question_state=qs,
|
|
policy_refs={"governance_policy_hash": "a" * 64},
|
|
)
|
|
h_b = preflight_node_hash(
|
|
question_state=qs,
|
|
policy_refs={"governance_policy_hash": "b" * 64},
|
|
)
|
|
assert h_a != h_b
|
|
|
|
|
|
def test_preflight_node_hash_includes_node_version():
|
|
"""node_version field is part of the hashed payload so a future
|
|
schema bump (preflight-node-v2 etc.) invalidates legacy nodes."""
|
|
from arborist.qa.dag import (
|
|
PREFLIGHT_NODE_VERSION,
|
|
build_preflight_node_payload,
|
|
)
|
|
payload = build_preflight_node_payload(
|
|
question_state={"x": 1},
|
|
)
|
|
assert payload["node_version"] == PREFLIGHT_NODE_VERSION
|
|
assert PREFLIGHT_NODE_VERSION == "preflight-node-v1"
|
|
|
|
|
|
def test_preflight_node_hash_handles_all_none():
|
|
"""Defensive — all clauses may be None during gradual rollout.
|
|
Hash stays stable."""
|
|
a = preflight_node_hash()
|
|
b = preflight_node_hash()
|
|
assert a == b
|
|
assert len(a) == 64
|
|
|
|
|
|
def test_dag_without_preflight_keeps_seven_stage_shape():
|
|
"""Backward-compat: omitting preflight_hash preserves the
|
|
pre-#000009 7-stage shape so legacy run_dag_root values
|
|
re-validate."""
|
|
out = build_run_dag(**_kw())
|
|
stages = [n["stage"] for n in out["nodes"]]
|
|
assert stages == [
|
|
"question", "retrieval", "context", "prompt",
|
|
"answer", "verify", "final_label",
|
|
]
|
|
assert len(out["nodes"]) == 7
|
|
|
|
|
|
def test_dag_with_preflight_inserts_eight_stage_shape():
|
|
"""Quote-mode + preflight_hash → 8 stages, preflight at
|
|
position 1 (between question and retrieval)."""
|
|
pre_hash = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier={"intensity": "SINGULAR", "is_broad": False},
|
|
answer_contract={"guard_enabled": True},
|
|
)
|
|
out = build_run_dag(**_kw(preflight_hash=pre_hash))
|
|
stages = [n["stage"] for n in out["nodes"]]
|
|
assert stages == [
|
|
"question", "preflight", "retrieval", "context",
|
|
"prompt", "answer", "verify", "final_label",
|
|
]
|
|
assert len(out["nodes"]) == 8
|
|
|
|
|
|
def test_dag_with_preflight_lattice_mode_ten_stages():
|
|
"""Pointer-mode + preflight_hash → 10 stages."""
|
|
pre_hash = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier={"intensity": "ALL", "is_broad": True},
|
|
answer_contract={"guard_enabled": True},
|
|
)
|
|
out = build_run_dag(**_kw(
|
|
preflight_hash=pre_hash,
|
|
evidence_map_root="d" * 64,
|
|
verifier_method="claim_lattice_pointer",
|
|
raw_answer_text="Some claim. [E1]",
|
|
parsed_lattice=[{"claim_text": "Some claim", "evidence_ids": ["e1"]}],
|
|
rendered_text="Some claim. [E1 | source]",
|
|
))
|
|
stages = [n["stage"] for n in out["nodes"]]
|
|
assert "preflight" in stages
|
|
assert stages.index("preflight") == 1 # right after question
|
|
assert len(stages) == 10
|
|
|
|
|
|
def test_dag_root_changes_when_preflight_hash_changes():
|
|
"""Different preflight inputs → different run_dag_root. This is
|
|
the audit-replay payoff: same model output + same verifier
|
|
verdict + DIFFERENT preflight policy = different cache row."""
|
|
pre_a = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier={"intensity": "SINGULAR"},
|
|
answer_contract={"apply_caps_active": False},
|
|
)
|
|
pre_b = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier={"intensity": "SINGULAR"},
|
|
answer_contract={"apply_caps_active": True},
|
|
)
|
|
a = build_run_dag(**_kw(preflight_hash=pre_a))
|
|
b = build_run_dag(**_kw(preflight_hash=pre_b))
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_dag_with_preflight_round_trips_through_verify():
|
|
"""The preflight stage's hash is part of the leaf list so
|
|
verify_run_dag must reconstruct the same root."""
|
|
import json
|
|
pre_hash = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_PARTIAL"},
|
|
quantifier={"intensity": "ALL", "is_broad": True},
|
|
answer_contract={"apply_caps_active": True, "claim_cap_resolved": 8},
|
|
)
|
|
out = build_run_dag(**_kw(preflight_hash=pre_hash))
|
|
blob = json.dumps(out, separators=(",", ":"))
|
|
assert verify_run_dag(blob) is True
|
|
|
|
|
|
# ---------------------------------------------------------------- reject-broad DAG (§8.2 A)
|
|
|
|
def test_reject_run_dag_three_stage_shape():
|
|
"""Reject-broad early-return path produces a 3-stage DAG:
|
|
question → preflight → final_label. Audit replay can read the
|
|
stage list and tell instantly that this row is a preflight
|
|
rejection (3 stages = reject path)."""
|
|
from arborist.qa.dag import build_reject_run_dag, preflight_node_hash
|
|
pre = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_BLOCKED"},
|
|
quantifier={"intensity": "ALL", "is_broad": True,
|
|
"scope_bound_hint": "unbounded"},
|
|
answer_contract={"reject_broad_active": True},
|
|
)
|
|
out = build_reject_run_dag(
|
|
question_hash="a" * 64,
|
|
preflight_hash=pre,
|
|
rejection_reason="preflight rejection — broad-unbounded.",
|
|
answer_text="BROAD-QUANTIFIER PREFLIGHT REJECTED",
|
|
violations=[{"kind": "BROAD_QUANTIFIER_REJECTED"}],
|
|
)
|
|
stages = [n["stage"] for n in out["nodes"]]
|
|
assert stages == ["question", "preflight", "final_label"]
|
|
assert len(stages) == 3
|
|
|
|
|
|
def test_reject_run_dag_root_changes_with_preflight_hash():
|
|
"""Two reject runs that differ only in the preflight payload
|
|
(e.g. different policy state at rejection time) must produce
|
|
different run_dag_root values."""
|
|
from arborist.qa.dag import build_reject_run_dag, preflight_node_hash
|
|
pre_a = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_BLOCKED"},
|
|
quantifier={"intensity": "ALL"},
|
|
policy_refs={"governance_policy_hash": "a" * 64},
|
|
)
|
|
pre_b = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_BLOCKED"},
|
|
quantifier={"intensity": "ALL"},
|
|
policy_refs={"governance_policy_hash": "b" * 64},
|
|
)
|
|
a = build_reject_run_dag(
|
|
question_hash="d" * 64, preflight_hash=pre_a,
|
|
rejection_reason="r", answer_text="x",
|
|
)
|
|
b = build_reject_run_dag(
|
|
question_hash="d" * 64, preflight_hash=pre_b,
|
|
rejection_reason="r", answer_text="x",
|
|
)
|
|
assert a["root"] != b["root"]
|
|
|
|
|
|
def test_reject_run_dag_round_trips_through_verify():
|
|
"""3-stage reject DAG must verify the same way as the standard
|
|
7/9/8/10-stage shapes."""
|
|
import json
|
|
from arborist.qa.dag import build_reject_run_dag, preflight_node_hash
|
|
pre = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_BLOCKED"},
|
|
quantifier={"intensity": "ALL"},
|
|
)
|
|
out = build_reject_run_dag(
|
|
question_hash="a" * 64,
|
|
preflight_hash=pre,
|
|
rejection_reason="preflight rejection",
|
|
answer_text="REJECTED",
|
|
)
|
|
blob = json.dumps(out, separators=(",", ":"))
|
|
assert verify_run_dag(blob) is True
|
|
|
|
|
|
# ---------------------------------------------------------------- preflight extraction (#000009 §7.2)
|
|
|
|
def test_extract_preflight_hash_from_blob_with_preflight():
|
|
"""Pull preflight stage hash out of a persisted run_dag_blob."""
|
|
import json
|
|
from arborist.qa.dag import build_run_dag, preflight_node_hash
|
|
from arborist.qa.query import _extract_preflight_hash_from_blob
|
|
pre = preflight_node_hash(
|
|
question_state={"preflight_result": "PREFLIGHT_OK"},
|
|
quantifier={"intensity": "SINGULAR"},
|
|
)
|
|
out = build_run_dag(**_kw(preflight_hash=pre))
|
|
blob = json.dumps(out, separators=(",", ":"))
|
|
extracted = _extract_preflight_hash_from_blob(blob)
|
|
assert extracted == pre
|
|
|
|
|
|
def test_extract_preflight_hash_returns_none_for_legacy_blob():
|
|
"""Legacy blobs (no preflight stage) return None — this is the
|
|
fall-through path for cache rows written before #000009."""
|
|
import json
|
|
from arborist.qa.query import _extract_preflight_hash_from_blob
|
|
out = build_run_dag(**_kw()) # no preflight_hash
|
|
blob = json.dumps(out, separators=(",", ":"))
|
|
assert _extract_preflight_hash_from_blob(blob) is None
|
|
|
|
|
|
def test_extract_preflight_hash_returns_none_for_empty_or_invalid():
|
|
from arborist.qa.query import _extract_preflight_hash_from_blob
|
|
assert _extract_preflight_hash_from_blob(None) is None
|
|
assert _extract_preflight_hash_from_blob("") is None
|
|
assert _extract_preflight_hash_from_blob("not json {{{") is None
|
|
assert _extract_preflight_hash_from_blob('"just a string"') is None
|
|
assert _extract_preflight_hash_from_blob("[]") is None # not a dict
|
|
assert _extract_preflight_hash_from_blob('{"nodes": []}') is None # no preflight
|