Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""Trajectory consistency checker for chapter 8.
|
|
|
|
Detects ungrounded claims, contradictions, hallucinated tool results, and
|
|
unsupported conclusions in agent trajectories. Purely heuristic and
|
|
deterministic: no network calls, no LLM dependency.
|
|
|
|
A trajectory is a list of step dictionaries. Each step may contain:
|
|
|
|
step_id int -- ordinal identifier (defaults to list index)
|
|
action str -- what the agent did in this step
|
|
claims list[str] -- assertions made by the agent
|
|
tool_result Any -- the actual result returned by a tool
|
|
claimed_tool_result Any -- what the agent says the tool returned
|
|
observation str -- a textual observation the agent received
|
|
final_answer str -- the agent's final conclusion (last step)
|
|
|
|
The checker scores four dimensions:
|
|
|
|
claim_grounding -- fraction of claims backed by prior evidence
|
|
contradiction_freedom -- 1 minus the contradiction rate
|
|
evidence_chain_integrity -- fraction of tool results reported faithfully
|
|
conclusion_support -- whether the final answer follows from evidence
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VIOLATION_UNGROUNDED = "ungrounded_claim"
|
|
VIOLATION_CONTRADICTION = "contradiction"
|
|
VIOLATION_HALLUCINATED = "hallucinated_result"
|
|
VIOLATION_UNSUPPORTED = "unsupported_conclusion"
|
|
|
|
DIMENSIONS = (
|
|
"claim_grounding",
|
|
"contradiction_freedom",
|
|
"evidence_chain_integrity",
|
|
"conclusion_support",
|
|
)
|
|
|
|
# Tokens that flip a claim's polarity.
|
|
_NEGATION_TOKENS = frozenset({
|
|
"not", "no", "never", "none", "nobody", "nothing", "neither",
|
|
"nor", "cannot", "cant", "wont", "dont", "doesnt", "didnt",
|
|
"isnt", "wasnt", "arent", "werent", "hasnt", "havent", "hadnt",
|
|
"wouldnt", "couldnt", "shouldnt",
|
|
})
|
|
|
|
# Stopwords excluded when computing token overlap for grounding.
|
|
_STOPWORDS = frozenset({
|
|
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
"to", "of", "in", "on", "at", "by", "for", "with", "about", "as",
|
|
"into", "from", "that", "this", "these", "those", "it", "its",
|
|
"has", "have", "had", "do", "does", "did", "will", "would", "can",
|
|
"could", "should", "shall", "may", "might", "must", "and", "or",
|
|
"but", "if", "then", "so", "than", "too", "very", "just", "also",
|
|
"i", "we", "you", "they", "he", "she", "my", "our", "your",
|
|
"been", "being", "am",
|
|
})
|
|
|
|
# Minimum significant-token overlap ratio for a claim to be considered grounded.
|
|
_GROUNDING_THRESHOLD = 0.5
|
|
|
|
# Minimum Jaccard similarity between claim cores for a contradiction check.
|
|
_CONTRADICTION_SIMILARITY = 0.5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class ConsistencyViolation:
|
|
"""A single consistency violation found in a trajectory step."""
|
|
|
|
step_id: int
|
|
violation_type: str # ungrounded_claim, contradiction, hallucinated_result, unsupported_conclusion
|
|
description: str
|
|
evidence: dict[str, Any]
|
|
|
|
|
|
@dataclass
|
|
class ConsistencyReport:
|
|
"""Structured report returned by ``check_trajectory``."""
|
|
|
|
total_steps: int
|
|
total_claims: int
|
|
violations: list[ConsistencyViolation]
|
|
dimension_scores: dict[str, float]
|
|
overall_consistency_score: float
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _tokenize(text: str) -> list[str]:
|
|
"""Lowercase alphanumeric tokens of length > 1."""
|
|
return [t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 1]
|
|
|
|
|
|
def _significant_tokens(text: str) -> set[str]:
|
|
"""Tokens that carry semantic weight (stopwords and negations removed)."""
|
|
return {
|
|
t
|
|
for t in _tokenize(text)
|
|
if t not in _STOPWORDS and t not in _NEGATION_TOKENS
|
|
}
|
|
|
|
|
|
def _serialize_evidence(value: Any) -> str:
|
|
"""Convert a tool result or observation into a comparable string."""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
try:
|
|
return json.dumps(value, sort_keys=True)
|
|
except (TypeError, ValueError):
|
|
return str(value)
|
|
|
|
|
|
def _step_id(step: dict[str, Any], idx: int) -> int:
|
|
sid = step.get("step_id", idx)
|
|
if isinstance(sid, bool) or not isinstance(sid, int):
|
|
return idx
|
|
return sid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TrajectoryConsistencyChecker:
|
|
"""Check agent trajectories for internal consistency."""
|
|
|
|
def __init__(self) -> None:
|
|
self.grounding_threshold: float = _GROUNDING_THRESHOLD
|
|
self.contradiction_similarity: float = _CONTRADICTION_SIMILARITY
|
|
|
|
# -- public API ---------------------------------------------------------
|
|
|
|
def check_trajectory(self, trajectory: list[dict[str, Any]]) -> ConsistencyReport:
|
|
"""Run all consistency checks and return a structured report."""
|
|
if not trajectory:
|
|
return ConsistencyReport(
|
|
total_steps=0,
|
|
total_claims=0,
|
|
violations=[],
|
|
dimension_scores={d: 1.0 for d in DIMENSIONS},
|
|
overall_consistency_score=1.0,
|
|
)
|
|
|
|
violations: list[ConsistencyViolation] = []
|
|
total_claims = 0
|
|
grounded_claims = 0
|
|
|
|
all_claims: list[tuple[int, str]] = []
|
|
evidence_strings: list[str] = []
|
|
|
|
steps_with_tool_results = 0
|
|
steps_with_valid_results = 0
|
|
|
|
final_answer: str | None = None
|
|
final_step_id: int | None = None
|
|
|
|
for idx, step in enumerate(trajectory):
|
|
sid = _step_id(step, idx)
|
|
|
|
# --- accumulate evidence from this step ------------------------
|
|
observation = step.get("observation")
|
|
if isinstance(observation, str) and observation.strip():
|
|
evidence_strings.append(observation)
|
|
|
|
tool_result = step.get("tool_result")
|
|
if tool_result is not None:
|
|
serialized = _serialize_evidence(tool_result)
|
|
if serialized:
|
|
evidence_strings.append(serialized)
|
|
steps_with_tool_results += 1
|
|
|
|
claimed = step.get("claimed_tool_result")
|
|
if claimed is not None:
|
|
if tool_result != claimed:
|
|
violations.append(ConsistencyViolation(
|
|
step_id=sid,
|
|
violation_type=VIOLATION_HALLUCINATED,
|
|
description=(
|
|
f"Step {sid} claims a tool result that differs "
|
|
f"from the actual result"
|
|
),
|
|
evidence={
|
|
"actual_result": tool_result,
|
|
"claimed_result": claimed,
|
|
},
|
|
))
|
|
else:
|
|
steps_with_valid_results += 1
|
|
else:
|
|
steps_with_valid_results += 1
|
|
|
|
# --- check claim grounding -------------------------------------
|
|
claims = step.get("claims", [])
|
|
if not isinstance(claims, list):
|
|
claims = []
|
|
for claim in claims:
|
|
if not isinstance(claim, str) or not claim.strip():
|
|
continue
|
|
total_claims += 1
|
|
all_claims.append((sid, claim))
|
|
if self.check_claim_grounded(claim, list(evidence_strings)):
|
|
grounded_claims += 1
|
|
else:
|
|
violations.append(ConsistencyViolation(
|
|
step_id=sid,
|
|
violation_type=VIOLATION_UNGROUNDED,
|
|
description=(
|
|
f"Claim at step {sid} is not grounded in prior "
|
|
f"evidence: {claim}"
|
|
),
|
|
evidence={
|
|
"claim": claim,
|
|
"available_evidence": list(evidence_strings),
|
|
},
|
|
))
|
|
|
|
# --- track final answer ---------------------------------------
|
|
fa = step.get("final_answer")
|
|
if isinstance(fa, str) and fa.strip():
|
|
final_answer = fa
|
|
final_step_id = sid
|
|
|
|
# --- contradictions ------------------------------------------------
|
|
contradiction_violations = self.find_contradictions(all_claims)
|
|
violations.extend(contradiction_violations)
|
|
|
|
# --- unsupported conclusion ---------------------------------------
|
|
conclusion_supported = True
|
|
if final_answer is not None and final_step_id is not None:
|
|
conclusion_evidence = evidence_strings + [text for _, text in all_claims]
|
|
if not self.check_claim_grounded(final_answer, conclusion_evidence):
|
|
conclusion_supported = False
|
|
violations.append(ConsistencyViolation(
|
|
step_id=final_step_id,
|
|
violation_type=VIOLATION_UNSUPPORTED,
|
|
description=(
|
|
f"Final answer at step {final_step_id} is not supported "
|
|
f"by the evidence chain"
|
|
),
|
|
evidence={
|
|
"final_answer": final_answer,
|
|
"available_evidence": conclusion_evidence,
|
|
},
|
|
))
|
|
|
|
# --- dimension scores ---------------------------------------------
|
|
claim_grounding = grounded_claims / total_claims if total_claims else 1.0
|
|
contradiction_freedom = (
|
|
max(0.0, 1.0 - len(contradiction_violations) / total_claims)
|
|
if total_claims
|
|
else 1.0
|
|
)
|
|
evidence_chain_integrity = (
|
|
steps_with_valid_results / steps_with_tool_results
|
|
if steps_with_tool_results
|
|
else 1.0
|
|
)
|
|
conclusion_support = 1.0 if conclusion_supported else 0.0
|
|
|
|
dimension_scores = {
|
|
"claim_grounding": round(claim_grounding, 4),
|
|
"contradiction_freedom": round(contradiction_freedom, 4),
|
|
"evidence_chain_integrity": round(evidence_chain_integrity, 4),
|
|
"conclusion_support": round(conclusion_support, 4),
|
|
}
|
|
overall = round(sum(dimension_scores.values()) / len(dimension_scores), 4)
|
|
|
|
return ConsistencyReport(
|
|
total_steps=len(trajectory),
|
|
total_claims=total_claims,
|
|
violations=violations,
|
|
dimension_scores=dimension_scores,
|
|
overall_consistency_score=overall,
|
|
)
|
|
|
|
def check_claim_grounded(self, claim: str, available_evidence: list[str]) -> bool:
|
|
"""Return ``True`` if *claim* is backed by any evidence string."""
|
|
claim_tokens = _significant_tokens(claim)
|
|
if not claim_tokens:
|
|
return True
|
|
for evidence in available_evidence:
|
|
ev_tokens = _significant_tokens(evidence)
|
|
if not ev_tokens:
|
|
continue
|
|
overlap = len(claim_tokens & ev_tokens) / len(claim_tokens)
|
|
if overlap >= self.grounding_threshold:
|
|
return True
|
|
return False
|
|
|
|
def find_contradictions(
|
|
self, claims: list[tuple[int, str]]
|
|
) -> list[ConsistencyViolation]:
|
|
"""Detect contradictions between claims across steps.
|
|
|
|
Two contradiction patterns are recognised:
|
|
|
|
* **polarity** -- one claim affirms X, a later claim denies X.
|
|
* **numeric** -- two claims share the same subject but cite
|
|
disjoint numeric values.
|
|
"""
|
|
violations: list[ConsistencyViolation] = []
|
|
parsed: list[tuple[int, str, bool, set[str], set[str]]] = []
|
|
|
|
for step_id, text in claims:
|
|
tokens = _tokenize(text)
|
|
negated = any(t in _NEGATION_TOKENS for t in tokens)
|
|
core = _significant_tokens(text)
|
|
numbers = set(re.findall(r"\d+", text.lower()))
|
|
parsed.append((step_id, text, negated, core, numbers))
|
|
|
|
for i in range(len(parsed)):
|
|
sid_a, text_a, neg_a, core_a, nums_a = parsed[i]
|
|
if not core_a:
|
|
continue
|
|
for j in range(i + 1, len(parsed)):
|
|
sid_b, text_b, neg_b, core_b, nums_b = parsed[j]
|
|
if sid_b <= sid_a:
|
|
continue
|
|
if not core_b:
|
|
continue
|
|
jaccard = len(core_a & core_b) / len(core_a | core_b)
|
|
if jaccard < self.contradiction_similarity:
|
|
continue
|
|
|
|
if neg_a != neg_b:
|
|
violations.append(ConsistencyViolation(
|
|
step_id=sid_b,
|
|
violation_type=VIOLATION_CONTRADICTION,
|
|
description=(
|
|
f"Claim at step {sid_b} contradicts claim at "
|
|
f"step {sid_a}"
|
|
),
|
|
evidence={
|
|
"earlier_step": sid_a,
|
|
"earlier_claim": text_a,
|
|
"later_step": sid_b,
|
|
"later_claim": text_b,
|
|
"contradiction_type": "polarity",
|
|
},
|
|
))
|
|
elif nums_a and nums_b and nums_a.isdisjoint(nums_b):
|
|
violations.append(ConsistencyViolation(
|
|
step_id=sid_b,
|
|
violation_type=VIOLATION_CONTRADICTION,
|
|
description=(
|
|
f"Claim at step {sid_b} contradicts numeric value "
|
|
f"in claim at step {sid_a}"
|
|
),
|
|
evidence={
|
|
"earlier_step": sid_a,
|
|
"earlier_claim": text_a,
|
|
"later_step": sid_b,
|
|
"later_claim": text_b,
|
|
"contradiction_type": "numeric",
|
|
"earlier_numbers": sorted(nums_a),
|
|
"later_numbers": sorted(nums_b),
|
|
},
|
|
))
|
|
return violations
|