failloud 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- failloud/__init__.py +51 -0
- failloud/checks/__init__.py +17 -0
- failloud/checks/completeness.py +241 -0
- failloud/checks/counts.py +169 -0
- failloud/checks/evidence.py +182 -0
- failloud/checks/placeholder.py +187 -0
- failloud/checks/stale.py +184 -0
- failloud/cli.py +190 -0
- failloud/engine.py +44 -0
- failloud/mcp_server.py +204 -0
- failloud/models.py +140 -0
- failloud/receipts.py +321 -0
- failloud/report.py +110 -0
- failloud/util.py +148 -0
- failloud-0.2.0.dist-info/METADATA +232 -0
- failloud-0.2.0.dist-info/RECORD +20 -0
- failloud-0.2.0.dist-info/WHEEL +5 -0
- failloud-0.2.0.dist-info/entry_points.txt +3 -0
- failloud-0.2.0.dist-info/licenses/LICENSE +21 -0
- failloud-0.2.0.dist-info/top_level.txt +1 -0
failloud/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""FailLoud — a verification harness for LLM agent outputs.
|
|
2
|
+
|
|
3
|
+
Agents rarely fail loudly. They return ``status: done`` with an empty
|
|
4
|
+
payload, claim an action no artifact backs, plan from state that went stale
|
|
5
|
+
two steps ago, report counts their own itemized record contradicts, and
|
|
6
|
+
ship deliverables with silent holes in them. FailLoud reads the output the
|
|
7
|
+
way a skeptical reviewer would and produces a failure report instead of a
|
|
8
|
+
shrug.
|
|
9
|
+
|
|
10
|
+
Quick start::
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from failloud import check_output
|
|
14
|
+
|
|
15
|
+
result = check_output(json.load(open("agent_output.json")))
|
|
16
|
+
if not result.passed:
|
|
17
|
+
print(result.verdict) # "FAIL"
|
|
18
|
+
for finding in result.errors:
|
|
19
|
+
print(finding.check_id, finding.path, finding.message)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .engine import run_checks, run_single
|
|
27
|
+
from .models import CheckConfig, CheckOutcome, CheckResult, Finding, Severity
|
|
28
|
+
from .receipts import Receipt, create_receipt, verify_chain, verify_receipt
|
|
29
|
+
|
|
30
|
+
__version__ = "0.2.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"check_output",
|
|
34
|
+
"run_checks",
|
|
35
|
+
"run_single",
|
|
36
|
+
"CheckConfig",
|
|
37
|
+
"CheckOutcome",
|
|
38
|
+
"CheckResult",
|
|
39
|
+
"Finding",
|
|
40
|
+
"Severity",
|
|
41
|
+
"Receipt",
|
|
42
|
+
"create_receipt",
|
|
43
|
+
"verify_receipt",
|
|
44
|
+
"verify_chain",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_output(doc: Any, config: CheckConfig | None = None) -> CheckResult:
|
|
50
|
+
"""Verify one agent output document. The main entry point."""
|
|
51
|
+
return run_checks(doc, config)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The check registry.
|
|
2
|
+
|
|
3
|
+
Each module in this package detects one class of silent failure and exposes
|
|
4
|
+
``CHECK_ID``, ``NAME``, ``DESCRIPTION`` and ``check(doc, config)``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
from . import completeness, counts, evidence, placeholder, stale
|
|
12
|
+
|
|
13
|
+
MODULES: list[ModuleType] = [placeholder, evidence, stale, counts, completeness]
|
|
14
|
+
|
|
15
|
+
BY_ID: dict[str, ModuleType] = {m.CHECK_ID: m for m in MODULES}
|
|
16
|
+
|
|
17
|
+
__all__ = ["MODULES", "BY_ID"]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""FL005 — missing_components.
|
|
2
|
+
|
|
3
|
+
Catches deliverables that are structurally present but silently incomplete:
|
|
4
|
+
a required field that exists on paper and is empty in fact, and — for
|
|
5
|
+
segmented media — stretches where a component should be and nothing is.
|
|
6
|
+
|
|
7
|
+
Production origin: a delivered video passed every existence check — right
|
|
8
|
+
file, right length, right format — while one segment inside it was silent.
|
|
9
|
+
Existence is not completeness. The check that exists because of that miss
|
|
10
|
+
measures the inside of the deliverable, not just its outline.
|
|
11
|
+
|
|
12
|
+
Three passes:
|
|
13
|
+
|
|
14
|
+
1. Required paths (``--require output.url`` / config) and JSON Schema
|
|
15
|
+
``required`` fields: missing *or* empty is an error.
|
|
16
|
+
2. Heuristic emptiness: fields inside the payload that are present but
|
|
17
|
+
empty while their siblings carry content are warnings — often benign,
|
|
18
|
+
always worth a look.
|
|
19
|
+
3. Segment integrity: zero-length segments and gaps between segments
|
|
20
|
+
longer than ``max_segment_gap_seconds`` are dead stretches, and are
|
|
21
|
+
errors.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..models import CheckConfig, Finding, Severity
|
|
29
|
+
from ..util import is_empty, normalize_key, resolve_path
|
|
30
|
+
|
|
31
|
+
CHECK_ID = "FL005"
|
|
32
|
+
NAME = "missing_components"
|
|
33
|
+
DESCRIPTION = (
|
|
34
|
+
"Required components missing or silently empty, and dead stretches "
|
|
35
|
+
"inside segmented deliverables."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
#: Fields that are legitimately empty in healthy outputs.
|
|
39
|
+
ALLOW_EMPTY_KEYS = {"error", "errors", "warnings", "cc", "bcc", "metadata", "parentid"}
|
|
40
|
+
|
|
41
|
+
SEGMENT_LIST_KEYS = {"segments", "scenes", "tracks", "clips", "chapters", "shots"}
|
|
42
|
+
START_KEYS = ("start", "start_sec", "start_time", "begin", "from")
|
|
43
|
+
END_KEYS = ("end", "end_sec", "end_time", "to")
|
|
44
|
+
DURATION_KEYS = ("duration", "duration_sec", "length", "duration_seconds")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _first_number(node: dict[str, Any], keys: tuple[str, ...]) -> float | None:
|
|
48
|
+
lowered = {normalize_key(k): v for k, v in node.items()}
|
|
49
|
+
for key in keys:
|
|
50
|
+
value = lowered.get(normalize_key(key))
|
|
51
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
52
|
+
return float(value)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _segment_span(segment: dict[str, Any]) -> tuple[float, float] | None:
|
|
57
|
+
start = _first_number(segment, START_KEYS)
|
|
58
|
+
end = _first_number(segment, END_KEYS)
|
|
59
|
+
duration = _first_number(segment, DURATION_KEYS)
|
|
60
|
+
if start is None and duration is None:
|
|
61
|
+
return None
|
|
62
|
+
start = start if start is not None else 0.0
|
|
63
|
+
if end is None and duration is not None:
|
|
64
|
+
end = start + duration
|
|
65
|
+
if end is None:
|
|
66
|
+
return None
|
|
67
|
+
return start, end
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_segments(doc: Any, config: CheckConfig) -> list[Finding]:
|
|
71
|
+
findings: list[Finding] = []
|
|
72
|
+
from ..util import iter_nodes
|
|
73
|
+
|
|
74
|
+
for path, value in iter_nodes(doc):
|
|
75
|
+
key = path.rsplit(".", 1)[-1]
|
|
76
|
+
if normalize_key(key) not in SEGMENT_LIST_KEYS or not isinstance(value, list):
|
|
77
|
+
continue
|
|
78
|
+
spans: list[tuple[float, float, int]] = []
|
|
79
|
+
for index, segment in enumerate(value):
|
|
80
|
+
if not isinstance(segment, dict):
|
|
81
|
+
continue
|
|
82
|
+
span = _segment_span(segment)
|
|
83
|
+
if span is None:
|
|
84
|
+
continue
|
|
85
|
+
start, end = span
|
|
86
|
+
spans.append((start, end, index))
|
|
87
|
+
if end - start <= 0:
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(
|
|
90
|
+
check_id=CHECK_ID,
|
|
91
|
+
code="zero_length_segment",
|
|
92
|
+
severity=Severity.ERROR,
|
|
93
|
+
path=f"{path}[{index}]",
|
|
94
|
+
message=(
|
|
95
|
+
f"Segment {index} has zero length "
|
|
96
|
+
f"({start:g}s–{end:g}s) — a component that "
|
|
97
|
+
"occupies a slot and contains nothing."
|
|
98
|
+
),
|
|
99
|
+
detail={"start": start, "end": end},
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
spans.sort()
|
|
103
|
+
for (prev_start, prev_end, _), (start, end, index) in zip(spans, spans[1:]):
|
|
104
|
+
gap = start - prev_end
|
|
105
|
+
if gap > config.max_segment_gap_seconds:
|
|
106
|
+
findings.append(
|
|
107
|
+
Finding(
|
|
108
|
+
check_id=CHECK_ID,
|
|
109
|
+
code="dead_stretch",
|
|
110
|
+
severity=Severity.ERROR,
|
|
111
|
+
path=f"{path}[{index}]",
|
|
112
|
+
message=(
|
|
113
|
+
f"Dead stretch of {gap:.1f}s before segment "
|
|
114
|
+
f"{index} (gap limit "
|
|
115
|
+
f"{config.max_segment_gap_seconds:g}s) — the "
|
|
116
|
+
"deliverable goes silent where content should be."
|
|
117
|
+
),
|
|
118
|
+
detail={"gap_seconds": round(gap, 3), "before_segment": index},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return findings
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_schema(node: Any, schema: dict[str, Any], path: str) -> list[Finding]:
|
|
125
|
+
"""Walk the supported JSON Schema subset: ``required`` + ``properties``
|
|
126
|
+
on objects, ``items`` on arrays."""
|
|
127
|
+
findings: list[Finding] = []
|
|
128
|
+
if not isinstance(schema, dict):
|
|
129
|
+
return findings
|
|
130
|
+
required = schema.get("required")
|
|
131
|
+
if isinstance(required, list) and isinstance(node, dict):
|
|
132
|
+
for key in required:
|
|
133
|
+
if key not in node:
|
|
134
|
+
findings.append(
|
|
135
|
+
Finding(
|
|
136
|
+
check_id=CHECK_ID,
|
|
137
|
+
code="missing_component",
|
|
138
|
+
severity=Severity.ERROR,
|
|
139
|
+
path=f"{path}.{key}",
|
|
140
|
+
message=f"Required component {key!r} is missing entirely.",
|
|
141
|
+
detail={"required_by": "schema"},
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
elif is_empty(node[key]):
|
|
145
|
+
findings.append(
|
|
146
|
+
Finding(
|
|
147
|
+
check_id=CHECK_ID,
|
|
148
|
+
code="empty_component",
|
|
149
|
+
severity=Severity.ERROR,
|
|
150
|
+
path=f"{path}.{key}",
|
|
151
|
+
message=(
|
|
152
|
+
f"Required component {key!r} is present but "
|
|
153
|
+
"empty — it satisfies the shape and delivers "
|
|
154
|
+
"nothing."
|
|
155
|
+
),
|
|
156
|
+
detail={"required_by": "schema"},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
properties = schema.get("properties")
|
|
160
|
+
if isinstance(properties, dict) and isinstance(node, dict):
|
|
161
|
+
for key, subschema in properties.items():
|
|
162
|
+
if key in node:
|
|
163
|
+
findings.extend(_check_schema(node[key], subschema, f"{path}.{key}"))
|
|
164
|
+
items = schema.get("items")
|
|
165
|
+
if isinstance(items, dict) and isinstance(node, list):
|
|
166
|
+
for index, item in enumerate(node):
|
|
167
|
+
findings.extend(_check_schema(item, items, f"{path}[{index}]"))
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check(doc: Any, config: CheckConfig) -> list[Finding]:
|
|
172
|
+
findings: list[Finding] = []
|
|
173
|
+
if not isinstance(doc, dict):
|
|
174
|
+
return findings
|
|
175
|
+
|
|
176
|
+
# Pass 1a: explicitly required dotted paths.
|
|
177
|
+
for dotted in config.required_paths:
|
|
178
|
+
found, value = resolve_path(doc, dotted)
|
|
179
|
+
if not found:
|
|
180
|
+
findings.append(
|
|
181
|
+
Finding(
|
|
182
|
+
check_id=CHECK_ID,
|
|
183
|
+
code="missing_component",
|
|
184
|
+
severity=Severity.ERROR,
|
|
185
|
+
path=f"$.{dotted}",
|
|
186
|
+
message=f"Required component {dotted!r} is missing entirely.",
|
|
187
|
+
detail={"required_by": "required_paths"},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
elif is_empty(value):
|
|
191
|
+
findings.append(
|
|
192
|
+
Finding(
|
|
193
|
+
check_id=CHECK_ID,
|
|
194
|
+
code="empty_component",
|
|
195
|
+
severity=Severity.ERROR,
|
|
196
|
+
path=f"$.{dotted}",
|
|
197
|
+
message=(
|
|
198
|
+
f"Required component {dotted!r} is present but empty "
|
|
199
|
+
"— it satisfies the shape and delivers nothing."
|
|
200
|
+
),
|
|
201
|
+
detail={"required_by": "required_paths"},
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Pass 1b: JSON Schema subset.
|
|
206
|
+
if config.schema:
|
|
207
|
+
findings.extend(_check_schema(doc, config.schema, "$"))
|
|
208
|
+
|
|
209
|
+
# Pass 2: heuristic emptiness inside the payload.
|
|
210
|
+
payload: Any = doc.get("output", doc)
|
|
211
|
+
if isinstance(payload, dict):
|
|
212
|
+
from ..util import iter_nodes
|
|
213
|
+
|
|
214
|
+
for path, value in iter_nodes(payload, "$.output" if "output" in doc else "$"):
|
|
215
|
+
if not isinstance(value, dict):
|
|
216
|
+
continue
|
|
217
|
+
populated = [k for k, v in value.items() if not is_empty(v)]
|
|
218
|
+
if not populated:
|
|
219
|
+
continue
|
|
220
|
+
for key, item in value.items():
|
|
221
|
+
if normalize_key(key) in ALLOW_EMPTY_KEYS:
|
|
222
|
+
continue
|
|
223
|
+
if is_empty(item):
|
|
224
|
+
findings.append(
|
|
225
|
+
Finding(
|
|
226
|
+
check_id=CHECK_ID,
|
|
227
|
+
code="empty_field",
|
|
228
|
+
severity=Severity.WARNING,
|
|
229
|
+
path=f"{path}.{key}",
|
|
230
|
+
message=(
|
|
231
|
+
f"Field {key!r} is present but empty while "
|
|
232
|
+
"its siblings carry content — verify it was "
|
|
233
|
+
"meant to be blank."
|
|
234
|
+
),
|
|
235
|
+
detail={},
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Pass 3: segment integrity.
|
|
240
|
+
findings.extend(_check_segments(doc, config))
|
|
241
|
+
return findings
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""FL004 — count_mismatch.
|
|
2
|
+
|
|
3
|
+
Catches reported numbers that disagree with the itemized record in the
|
|
4
|
+
same document: the summary claims 12, the list contains 9. The claimed
|
|
5
|
+
number is the one that travels — into dashboards, digests, and decisions —
|
|
6
|
+
while the list is the only part that was ever true.
|
|
7
|
+
|
|
8
|
+
Production origin: a report counted actions *attempted* while its audience
|
|
9
|
+
read them as actions *completed*. Attempted and completed differed by the
|
|
10
|
+
failures nobody itemized. The rule that came out of it: any count a reader
|
|
11
|
+
will repeat must reconcile with a list a reader can count.
|
|
12
|
+
|
|
13
|
+
What is verified (structured data only — prose numbers are out of scope):
|
|
14
|
+
|
|
15
|
+
- A numeric claim (``counts.sent``, ``emails_sent_count``, ``total_sent``)
|
|
16
|
+
whose metric has an itemized list in the document must equal the list's
|
|
17
|
+
length.
|
|
18
|
+
- A ``total`` inside a counts block must equal the sum of its siblings.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..models import CheckConfig, Finding, Severity
|
|
27
|
+
from ..util import iter_nodes, key_tokens, normalize_key
|
|
28
|
+
|
|
29
|
+
CHECK_ID = "FL004"
|
|
30
|
+
NAME = "count_mismatch"
|
|
31
|
+
DESCRIPTION = (
|
|
32
|
+
"A reported count that does not match the itemized record in the same "
|
|
33
|
+
"document (or a total that does not match its own parts)."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
COUNT_CONTAINER_KEYS = {"counts", "stats", "summarycounts", "totals", "metrics"}
|
|
37
|
+
COUNT_KEY_RES = (
|
|
38
|
+
re.compile(r"^(.+?)_count$"),
|
|
39
|
+
re.compile(r"^count_(.+)$"),
|
|
40
|
+
re.compile(r"^total_(.+)$"),
|
|
41
|
+
re.compile(r"^num_(.+)$"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _metric_of_count_key(key: str) -> str | None:
|
|
46
|
+
for pattern in COUNT_KEY_RES:
|
|
47
|
+
match = pattern.match(key.lower())
|
|
48
|
+
if match:
|
|
49
|
+
return match.group(1)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_number(value: Any) -> bool:
|
|
54
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _names_match(claim_metric: str, array_metric: str) -> bool:
|
|
58
|
+
a, b = normalize_key(claim_metric), normalize_key(array_metric)
|
|
59
|
+
if a == b:
|
|
60
|
+
return True
|
|
61
|
+
if a.rstrip("s") == b.rstrip("s") and a.rstrip("s"):
|
|
62
|
+
return True
|
|
63
|
+
tokens_a, tokens_b = key_tokens(claim_metric), key_tokens(array_metric)
|
|
64
|
+
if tokens_a and tokens_b and (tokens_a <= tokens_b or tokens_b <= tokens_a):
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check(doc: Any, config: CheckConfig) -> list[Finding]:
|
|
70
|
+
findings: list[Finding] = []
|
|
71
|
+
if not isinstance(doc, (dict, list)):
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
# Collect itemized lists: (metric name, path, length).
|
|
75
|
+
arrays: list[tuple[str, str, int]] = []
|
|
76
|
+
for path, value in iter_nodes(doc):
|
|
77
|
+
if isinstance(value, list) and path != "$":
|
|
78
|
+
metric = path.rsplit(".", 1)[-1]
|
|
79
|
+
if metric.startswith("$"):
|
|
80
|
+
continue
|
|
81
|
+
arrays.append((metric, path, len(value)))
|
|
82
|
+
|
|
83
|
+
# Collect numeric claims: (metric name, path, claimed value).
|
|
84
|
+
claims: list[tuple[str, str, float]] = []
|
|
85
|
+
seen: set[tuple[str, float]] = set()
|
|
86
|
+
|
|
87
|
+
def add_claim(metric: str, path: str, value: float) -> None:
|
|
88
|
+
dedupe_key = (normalize_key(metric), float(value))
|
|
89
|
+
if dedupe_key in seen:
|
|
90
|
+
return
|
|
91
|
+
seen.add(dedupe_key)
|
|
92
|
+
claims.append((metric, path, value))
|
|
93
|
+
|
|
94
|
+
for path, value in iter_nodes(doc):
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
continue
|
|
97
|
+
parent_key = normalize_key(path.rsplit(".", 1)[-1]) if path != "$" else ""
|
|
98
|
+
for key, item in value.items():
|
|
99
|
+
if not _is_number(item):
|
|
100
|
+
continue
|
|
101
|
+
item_path = f"{path}.{key}"
|
|
102
|
+
if parent_key in COUNT_CONTAINER_KEYS:
|
|
103
|
+
add_claim(key, item_path, float(item))
|
|
104
|
+
else:
|
|
105
|
+
metric = _metric_of_count_key(key)
|
|
106
|
+
if metric is not None:
|
|
107
|
+
add_claim(metric, item_path, float(item))
|
|
108
|
+
|
|
109
|
+
# A "total" must equal the sum of the siblings it totals.
|
|
110
|
+
for path, value in iter_nodes(doc):
|
|
111
|
+
if not isinstance(value, dict):
|
|
112
|
+
continue
|
|
113
|
+
parent_key = normalize_key(path.rsplit(".", 1)[-1]) if path != "$" else ""
|
|
114
|
+
if parent_key not in COUNT_CONTAINER_KEYS:
|
|
115
|
+
continue
|
|
116
|
+
numeric = {k: v for k, v in value.items() if _is_number(v)}
|
|
117
|
+
for key, total in numeric.items():
|
|
118
|
+
if normalize_key(key) in {"total", "grandtotal", "count"} and len(numeric) >= 3:
|
|
119
|
+
parts = sum(v for k, v in numeric.items() if k != key)
|
|
120
|
+
if float(total) != float(parts):
|
|
121
|
+
findings.append(
|
|
122
|
+
Finding(
|
|
123
|
+
check_id=CHECK_ID,
|
|
124
|
+
code="count_total_mismatch",
|
|
125
|
+
severity=Severity.ERROR,
|
|
126
|
+
path=f"{path}.{key}",
|
|
127
|
+
message=(
|
|
128
|
+
f"Reported total {total:g} does not equal "
|
|
129
|
+
f"the sum of its parts ({parts:g})."
|
|
130
|
+
),
|
|
131
|
+
detail={"claimed": total, "sum_of_parts": parts},
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Every claim with an itemized list must reconcile with it.
|
|
136
|
+
for metric, path, claimed in claims:
|
|
137
|
+
matching = [a for a in arrays if _names_match(metric, a[0])]
|
|
138
|
+
if not matching:
|
|
139
|
+
continue # Nothing itemized: the claim is unverifiable, not wrong.
|
|
140
|
+
# Prefer the longest list among name matches — the fullest record.
|
|
141
|
+
array_metric, array_path, actual = max(matching, key=lambda a: a[2])
|
|
142
|
+
if float(actual) != claimed:
|
|
143
|
+
inflated = claimed > actual
|
|
144
|
+
findings.append(
|
|
145
|
+
Finding(
|
|
146
|
+
check_id=CHECK_ID,
|
|
147
|
+
code="count_mismatch",
|
|
148
|
+
severity=Severity.ERROR,
|
|
149
|
+
path=path,
|
|
150
|
+
message=(
|
|
151
|
+
f"Count for {metric!r} is reported as {claimed:g} "
|
|
152
|
+
f"but the itemized record at {array_path} contains "
|
|
153
|
+
f"{actual} item(s) — "
|
|
154
|
+
+ (
|
|
155
|
+
"the report overstates what the record supports."
|
|
156
|
+
if inflated
|
|
157
|
+
else "the report understates its own record."
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
detail={
|
|
161
|
+
"metric": metric,
|
|
162
|
+
"claimed": claimed,
|
|
163
|
+
"actual": actual,
|
|
164
|
+
"inflated": inflated,
|
|
165
|
+
"items_path": array_path,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return findings
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""FL002 — unbacked_claim.
|
|
2
|
+
|
|
3
|
+
Catches claims of completed real-world actions that carry no evidence: the
|
|
4
|
+
agent says it sent, posted, published, updated, deployed — and nothing in
|
|
5
|
+
the output (no message id, no URL, no receipt, no artifact) backs the claim.
|
|
6
|
+
|
|
7
|
+
Production origin: a run reported a batch of messages as sent. One address
|
|
8
|
+
had bounced; the report never knew, because "sent" was asserted by the same
|
|
9
|
+
component that performed the send and nothing downstream demanded proof.
|
|
10
|
+
The rule that came out of it: a claim without evidence is a rumor.
|
|
11
|
+
|
|
12
|
+
Two passes:
|
|
13
|
+
|
|
14
|
+
1. Structured claims — every entry in a ``claims``/``actions`` list that
|
|
15
|
+
asserts a completed action must carry at least one evidence field.
|
|
16
|
+
2. Narrative claims — if a ``summary``/``message`` field asserts completed
|
|
17
|
+
actions in prose while the *entire document* contains no evidence at
|
|
18
|
+
all, the narrative is flagged once per field.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..models import CheckConfig, Finding, Severity
|
|
27
|
+
from ..util import has_content, is_empty, iter_nodes, normalize_key
|
|
28
|
+
from .placeholder import COMPLETION_VALUES
|
|
29
|
+
|
|
30
|
+
CHECK_ID = "FL002"
|
|
31
|
+
NAME = "unbacked_claim"
|
|
32
|
+
DESCRIPTION = (
|
|
33
|
+
"A claimed action (sent, posted, published, updated…) with no evidence "
|
|
34
|
+
"— no id, URL, receipt, or artifact — anywhere to back it."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Keys whose non-empty value counts as evidence for a claim.
|
|
38
|
+
EVIDENCE_KEYS = {
|
|
39
|
+
"evidence",
|
|
40
|
+
"proof",
|
|
41
|
+
"artifact",
|
|
42
|
+
"artifacts",
|
|
43
|
+
"url",
|
|
44
|
+
"urls",
|
|
45
|
+
"link",
|
|
46
|
+
"links",
|
|
47
|
+
"id",
|
|
48
|
+
"messageid",
|
|
49
|
+
"recordid",
|
|
50
|
+
"receipt",
|
|
51
|
+
"receiptid",
|
|
52
|
+
"confirmation",
|
|
53
|
+
"confirmationid",
|
|
54
|
+
"externalid",
|
|
55
|
+
"path",
|
|
56
|
+
"file",
|
|
57
|
+
"hash",
|
|
58
|
+
"sha",
|
|
59
|
+
"permalink",
|
|
60
|
+
"reference",
|
|
61
|
+
"ticket",
|
|
62
|
+
"postid",
|
|
63
|
+
"threadid",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#: Document keys that conventionally hold the list of claimed actions.
|
|
67
|
+
CLAIM_LIST_KEYS = {"claims", "actions", "actionstaken", "steps", "completedactions"}
|
|
68
|
+
|
|
69
|
+
#: Past-tense action verbs that assert something happened in the world.
|
|
70
|
+
CLAIM_VERB_RE = re.compile(
|
|
71
|
+
r"\b(sent|posted|published|emailed|uploaded|deployed|updated|created|"
|
|
72
|
+
r"deleted|booked|charged|notified|delivered|filed|submitted|released|"
|
|
73
|
+
r"merged|announced)\b",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
URL_RE = re.compile(r"https?://[^\s)\]]+")
|
|
78
|
+
|
|
79
|
+
#: Free-text fields where narrative claims usually live.
|
|
80
|
+
TEXT_CLAIM_KEYS = {"summary", "message", "report", "description", "notes", "result"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _value_has_evidence(value: Any) -> bool:
|
|
84
|
+
"""True if *value* contains, at any depth, an evidence-named field
|
|
85
|
+
holding actual content. An ``evidence`` object whose fields are all
|
|
86
|
+
empty is a container, not evidence, and does not count."""
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
for key, item in value.items():
|
|
89
|
+
if normalize_key(key) in EVIDENCE_KEYS and has_content(item):
|
|
90
|
+
return True
|
|
91
|
+
if _value_has_evidence(item):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
if isinstance(value, list):
|
|
95
|
+
return any(_value_has_evidence(item) for item in value)
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _claim_asserts_completion(claim: dict[str, Any]) -> str | None:
|
|
100
|
+
"""Return the asserted action name if *claim* says something was done,
|
|
101
|
+
else None (planned, in-progress, or honestly failed claims pass)."""
|
|
102
|
+
status = claim.get("status") or claim.get("state")
|
|
103
|
+
if isinstance(status, str):
|
|
104
|
+
lowered = status.strip().lower()
|
|
105
|
+
if lowered in {"failed", "error", "skipped", "pending", "planned", "cancelled"}:
|
|
106
|
+
return None
|
|
107
|
+
for key in ("action", "type", "verb", "did", "name", "operation"):
|
|
108
|
+
value = claim.get(key)
|
|
109
|
+
if isinstance(value, str) and value.strip():
|
|
110
|
+
return value.strip()
|
|
111
|
+
if claim.get("done") is True or claim.get("completed") is True:
|
|
112
|
+
return "completed action"
|
|
113
|
+
if isinstance(status, str) and status.strip().lower() in COMPLETION_VALUES:
|
|
114
|
+
return "completed action"
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _document_has_evidence(doc: Any) -> bool:
|
|
119
|
+
for _, value in iter_nodes(doc):
|
|
120
|
+
if isinstance(value, dict):
|
|
121
|
+
for key, item in value.items():
|
|
122
|
+
if normalize_key(key) in EVIDENCE_KEYS and not is_empty(item):
|
|
123
|
+
return True
|
|
124
|
+
if isinstance(value, str) and URL_RE.search(value):
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check(doc: Any, config: CheckConfig) -> list[Finding]:
|
|
130
|
+
findings: list[Finding] = []
|
|
131
|
+
if not isinstance(doc, (dict, list)):
|
|
132
|
+
return findings
|
|
133
|
+
|
|
134
|
+
# Pass 1: structured claims.
|
|
135
|
+
for path, value in iter_nodes(doc):
|
|
136
|
+
key = path.rsplit(".", 1)[-1]
|
|
137
|
+
if normalize_key(key) not in CLAIM_LIST_KEYS or not isinstance(value, list):
|
|
138
|
+
continue
|
|
139
|
+
for index, claim in enumerate(value):
|
|
140
|
+
if not isinstance(claim, dict):
|
|
141
|
+
continue
|
|
142
|
+
action = _claim_asserts_completion(claim)
|
|
143
|
+
if action is None:
|
|
144
|
+
continue
|
|
145
|
+
if not _value_has_evidence(claim):
|
|
146
|
+
findings.append(
|
|
147
|
+
Finding(
|
|
148
|
+
check_id=CHECK_ID,
|
|
149
|
+
code="unbacked_claim",
|
|
150
|
+
severity=Severity.ERROR,
|
|
151
|
+
path=f"{path}[{index}]",
|
|
152
|
+
message=(
|
|
153
|
+
f"Claimed action {action!r} has no evidence — no "
|
|
154
|
+
"id, URL, receipt, or artifact in the claim "
|
|
155
|
+
"backs that it happened."
|
|
156
|
+
),
|
|
157
|
+
detail={"action": action},
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Pass 2: narrative claims with zero evidence in the whole document.
|
|
162
|
+
if isinstance(doc, dict) and not _document_has_evidence(doc):
|
|
163
|
+
for key, value in doc.items():
|
|
164
|
+
if normalize_key(key) in {normalize_key(k) for k in TEXT_CLAIM_KEYS}:
|
|
165
|
+
if isinstance(value, str) and CLAIM_VERB_RE.search(value):
|
|
166
|
+
verbs = sorted({m.lower() for m in CLAIM_VERB_RE.findall(value)})
|
|
167
|
+
findings.append(
|
|
168
|
+
Finding(
|
|
169
|
+
check_id=CHECK_ID,
|
|
170
|
+
code="unbacked_claim",
|
|
171
|
+
severity=Severity.ERROR,
|
|
172
|
+
path=f"$.{key}",
|
|
173
|
+
message=(
|
|
174
|
+
"Narrative claims completed actions "
|
|
175
|
+
f"({', '.join(verbs)}) but the document "
|
|
176
|
+
"contains no evidence — no id, URL, receipt, "
|
|
177
|
+
"or artifact — anywhere."
|
|
178
|
+
),
|
|
179
|
+
detail={"verbs": verbs},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return findings
|