diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bcc0fb7..6415ee8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,6 +54,46 @@ uv run --frozen lexbench-webfetch inspect-revision \
the field differences, source hashes and executed controls. It preserves the
original input files and the revision's existing dataset identity.
+## Main content and shared acceptance
+
+Choose the required content from the reader's task and an independent page
+observation before writing matchers. An article needs its substantive sections;
+a feed needs the visible entries and their task-relevant fields; a product list
+needs the information the task asks to retrieve. A search landing page can
+legitimately have no article body. Navigation and footer words do not establish
+that a news feed or article was retrieved.
+
+Use the existing fact, body-region, record, pattern, resource and file checks.
+Combine the applicable requirements in the task definition. For record-only or
+resource-only deliveries, set `content_score_applicable: false` to omit the default
+body-length and literal checks; explicit facts and body-region requirements still
+apply. Do not implement a
+separate scorer for a site or report family. `score_attempt` exposes the selected
+`acceptance_checks`; `task_passed` requires all of them. Undeclared checks are
+omitted. Missing evidence for a declared requirement is unavailable and cannot
+pass. Full-text overlap remains diagnostic unless an explicit acceptance rule
+requires the content.
+
+A positive record-count requirement can own acceptance while page-shell anchors
+remain diagnostic, using `stable_text_role: "diagnostic"`. The same role applies
+to every record schema.
+
+`required_facts` are requirements even when a stable record contract is present.
+Do not put obsolete prices, rankings or changing membership in this field just
+to obtain diagnostic overlap metrics. Keep historical reference text in its
+reference field and declare current, independently supported requirements.
+A contract can select stable field rules for changing records without freezing
+old membership, but nonempty labels alone do not prove the records are truthful
+or that all main page sections are covered.
+
+Test a complete answer, the page shell without its main content, and an answer
+missing each required section or record field. Include equivalent supported
+serializations. Compare source, reference and output separately; an extractor's
+own output cannot qualify its reference. Preserve frozen task versions when
+adding coverage: new source observations do not retroactively describe an old
+provider call. A scoring change requires a fresh scoring identity and an audited
+offline recomputation before any published numbers change.
+
## License
Contributions use the [Apache License 2.0](LICENSE). Preserve third-party licenses, copyright notices and source attributions described in [THIRD_PARTY](THIRD_PARTY.md).
diff --git a/src/lexbench_webfetch/definitions/evaluation/scoring-regressions.json b/src/lexbench_webfetch/definitions/evaluation/scoring-regressions.json
index fb1d2cc..1c8938d 100644
--- a/src/lexbench_webfetch/definitions/evaluation/scoring-regressions.json
+++ b/src/lexbench_webfetch/definitions/evaluation/scoring-regressions.json
@@ -56771,6 +56771,631 @@
"ok": true,
"expected_task_pass": false
},
+ {
+ "id": "unified-body-complete-delivery-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "body"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-body-missing-fact-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "body"
+ },
+ "artifact": "[Portable speaker](/item/1)\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-body-missing-resource-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "body"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-structure-complete-delivery-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "structure"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-structure-missing-fact-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "structure"
+ },
+ "artifact": "[Portable speaker](/item/1)\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-structure-missing-resource-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "structure"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-resource-complete-delivery-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "resource"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-resource-missing-fact-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "resource"
+ },
+ "artifact": "[Portable speaker](/item/1)\n[Manual](/manual.pdf)",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-resource-missing-resource-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "required_facts": [
+ {
+ "id": "description",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ],
+ "expected_resources": [
+ {
+ "id": "manual",
+ "urls": [
+ "/manual.pdf"
+ ]
+ }
+ ],
+ "task_kind": "resource"
+ },
+ "artifact": "[Portable speaker](/item/1)\nBattery lasts twelve hours",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-expected_any-complete-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "expected_any": [
+ "Required introduction"
+ ]
+ },
+ "artifact": "Required introduction\n[Item](/item/1)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-expected_any-missing-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "expected_any": [
+ "Required introduction"
+ ]
+ },
+ "artifact": "[Item](/item/1)",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-min_content_chars-complete-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "min_content_chars": 80
+ },
+ "artifact": "Required introduction and a substantive explanation of a portable device lasting twelve hours.\n[Item](/item/1)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-min_content_chars-missing-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_anchors": []
+ },
+ "min_content_chars": 80
+ },
+ "artifact": "[Item](/item/1)",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-document-language-matching-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "track": "web",
+ "expected_final_url": "https://example.test/",
+ "expected_document_language": "en",
+ "page_identity_required": true
+ },
+ "artifact": "Article body",
+ "metadata": {
+ "document_language": "en"
+ },
+ "final_url": "https://example.test/",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-document-language-wrong-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "track": "web",
+ "expected_final_url": "https://example.test/",
+ "expected_document_language": "en",
+ "page_identity_required": true
+ },
+ "artifact": "Article body",
+ "metadata": {
+ "document_language": "fr"
+ },
+ "final_url": "https://example.test/",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "unified-absent-document-language-empty-tag-passes",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "expected_document_language_present": false,
+ "page_identity_required": true
+ },
+ "artifact": "Article body",
+ "metadata": {
+ "document_language": ""
+ },
+ "final_url": "https://example.test/",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "unified-absent-document-language-unexpected-tag-fails",
+ "category": "structured-extraction",
+ "root_cause": "declared_requirement_ignored_by_task_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "expected_document_language_present": false,
+ "page_identity_required": true
+ },
+ "artifact": "Article body",
+ "metadata": {
+ "document_language": "en"
+ },
+ "final_url": "https://example.test/",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "diagnostic-shell-with-required-record-record",
+ "category": "structured-extraction",
+ "root_cause": "page_shell_role_tied_to_record_schema",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_role": "diagnostic",
+ "stable_text_anchors": [
+ {
+ "id": "shell",
+ "alternatives": [
+ "Account Privacy Terms"
+ ]
+ }
+ ]
+ }
+ },
+ "artifact": "[Speaker](/item/1)",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "diagnostic-shell-with-required-record-shell",
+ "category": "structured-extraction",
+ "root_cause": "page_shell_role_tied_to_record_schema",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "acceptance_contract": {
+ "version": 1,
+ "schema": "named_link",
+ "minimum_records": 1,
+ "hosts": [
+ "example.test"
+ ],
+ "entry_path": "/item/\\d+",
+ "stable_text_role": "diagnostic",
+ "stable_text_anchors": [
+ {
+ "id": "shell",
+ "alternatives": [
+ "Account Privacy Terms"
+ ]
+ }
+ ]
+ }
+ },
+ "artifact": "Account Privacy Terms",
+ "ok": true,
+ "expected_task_pass": false
+ },
+ {
+ "id": "facts-without-undeclared-records-complete",
+ "category": "structured-extraction",
+ "root_cause": "undeclared_record_check_from_output_kind",
+ "failure_mode": "false-negative",
+ "case": {
+ "url": "https://example.test/",
+ "task_kind": "structure",
+ "required_facts": [
+ {
+ "id": "duration",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ]
+ },
+ "artifact": "Battery lasts twelve hours",
+ "ok": true,
+ "expected_task_pass": true
+ },
+ {
+ "id": "facts-without-undeclared-records-missing",
+ "category": "structured-extraction",
+ "root_cause": "undeclared_record_check_from_output_kind",
+ "failure_mode": "false-positive",
+ "case": {
+ "url": "https://example.test/",
+ "task_kind": "structure",
+ "required_facts": [
+ {
+ "id": "duration",
+ "alternatives": [
+ "Battery lasts twelve hours"
+ ]
+ }
+ ]
+ },
+ "artifact": "Home Terms",
+ "ok": true,
+ "expected_task_pass": false
+ },
{
"id": "localized-required-navigation-english",
"category": "acceptance-contract",
diff --git a/src/lexbench_webfetch/dynamic_reference.py b/src/lexbench_webfetch/dynamic_reference.py
index e9c2a61..d96dec3 100644
--- a/src/lexbench_webfetch/dynamic_reference.py
+++ b/src/lexbench_webfetch/dynamic_reference.py
@@ -1,5 +1,4 @@
-"""Field contracts for independently observed, finite dynamic practice pages."""
-import re
+"""Shared field layouts for owned record matching."""
def entry_field_orders(fields):
@@ -24,35 +23,3 @@ def entry_field_orders(fields):
body = tuple(f for f in ('name', 'price', 'financing') if f in fields)
return [body + ('shipping',), ('shipping',) + body] if 'shipping' in fields else [body]
return [layout for layout in layouts if frozenset(layout) == fields]
-
-
-def entry_facts(case_id, entries):
- fields = (['quote', 'author', 'tags'] if case_id in {'dynamic-quotes-api','dynamic-delayed'}
- else ['name','price'] if case_id=='dynamic-products-api'
- else ['price','title','description','reviews'] if case_id=='dynamic-catalog'
- else ['date','text'] if case_id in {'dynamic-reviews-graphql','dynamic-product-reviews'}
- else ['name','description'] if case_id=='dynamic-angular-phones'
- else ['method','path','summary'] if case_id=='dynamic-swagger'
- else ['family'] if case_id=='dynamic-iframe'
- else ['name','price','financing','shipping'] if case_id=='dynamic-react-cart' else None)
- if not fields:raise ValueError('Unknown dynamic case')
- facts=[]
- for i,entry in enumerate(entries,1):
- values=[line.strip() for line in entry.splitlines() if line.strip()]
- entry_fields = fields
- if case_id == 'dynamic-react-cart':
- # Shipping and financing are optional visible fields on these cards.
- entry_fields = ['name', 'price']
- if len(values)>2 and values[2].startswith('or '):entry_fields.append('financing')
- if values[-1]=='Free shipping':entry_fields.append('shipping')
- if len(values)!=len(entry_fields):raise ValueError(f'Incomplete or unexpected fields: {case_id} entry {i}')
- for field,value in zip(entry_fields,values):
- if field=='author':value=value.removeprefix('by ')
- if field=='tags':value=value.removeprefix('Tags: ')
- alternatives=[value]
- # Numeric count + its label can be adjacent in Markdown extraction.
- # Keep the whole number/unit, never remove arbitrary word boundaries.
- if field=='reviews' and re.fullmatch(r'\d+\s+reviews?',value):
- alternatives.append(re.sub(r'\s+','',value))
- facts.append({'id':f'entry-{i}-{field}','alternatives':alternatives})
- return facts
diff --git a/src/lexbench_webfetch/metrics.py b/src/lexbench_webfetch/metrics.py
index 95b7202..7616c22 100644
--- a/src/lexbench_webfetch/metrics.py
+++ b/src/lexbench_webfetch/metrics.py
@@ -468,14 +468,17 @@ def task_passed(ok: bool, metrics: dict, task_kind: str | None = None) -> bool:
return False
if not task_measured(metrics, task_kind) or metrics.get('deadline_overrun'):
return False
+ if "acceptance_checks" in metrics:
+ checks = metrics["acceptance_checks"]
+ return bool(ok and metrics.get("request_ok", ok) and checks
+ and all(value is True for value in checks.values()))
if "stable_task_pass" in metrics:
combined = (metrics.get("content_pass")
- if ("composite_acceptance_pass" in metrics
- or metrics.get("body_region_results")
- or metrics.get("pattern_results"))
+ if ("fact_coverage_pass" in metrics
+ or "composite_acceptance_pass" in metrics
+ or metrics.get("body_region_results") or metrics.get("pattern_results"))
else True)
- return bool(ok and metrics["stable_task_pass"]
- and combined
+ return bool(ok and metrics["stable_task_pass"] and combined
and metrics.get("body_coverage_pass") is not False)
field = {'structure':'list_task_pass', 'resource':'resource_pass', 'file':'raw_file_pass'}.get(task_kind, 'content_pass')
return bool(ok and metrics.get(field))
@@ -489,6 +492,9 @@ def task_measured(metrics: dict, task_kind: str | None = None) -> bool:
kind = task_kind or metrics.get('task_kind', 'body')
if metrics.get('page_identity_required') and metrics.get('page_identity_status') != 'verified':
return True
+ if 'acceptance_checks' in metrics and any(
+ value is None for value in metrics['acceptance_checks'].values()):
+ return False
return kind != 'file' or isinstance(metrics.get('raw_file_pass'), bool)
@@ -802,6 +808,7 @@ def evaluate_content(
"content_pass": content_pass,
"pattern_results": pattern_results,
"legacy_marker_pass": legacy_pass,
+ "content_length_pass": length_pass,
"matched_markers": matched,
"expected_markers": expected,
"marker_hits": len(matched),
@@ -812,6 +819,8 @@ def evaluate_content(
"fact_total": len(fact_results),
"fact_coverage": fact_coverage,
"fact_coverage_threshold": fact_coverage_threshold,
+ "fact_coverage_pass": fact_coverage >= fact_coverage_threshold,
+ "pattern_pass": all(item["pass"] for item in pattern_results),
"forbidden_requirement_hits": forbidden,
"diagnostic_hits": [item for item in (diagnostic_any or [])
if (text := normalize_for_match(item)) and forbidden_in_text(folded, text)],
@@ -851,6 +860,8 @@ def evaluate_content(
body_requirements
or (composite_acceptance and composite_acceptance.get("groups"))
or acceptance_contract.get("page_patterns")
+ or (type(acceptance_contract.get("minimum_records")) is int
+ and acceptance_contract["minimum_records"] > 0)
):
raise ValueError(
"Diagnostic stable text requires body, composite, or page-pattern acceptance"
@@ -885,7 +896,8 @@ def evaluate_content(
if "content_too_short" not in result["stable_task_errors"]:
result["stable_task_errors"].append("content_too_short")
result["content_pass"] = bool(
- declared_body_pass and declared_pattern_pass and result["stable_task_pass"] and not forbidden
+ declared_body_pass and declared_pattern_pass and result["stable_task_pass"]
+ and result["fact_coverage_pass"] and legacy_pass and length_pass and not forbidden
)
result["content_decision_basis"] = (
"declared_content_requirements_with_stable_text_diagnostics"
diff --git a/src/lexbench_webfetch/risk_control_dataset.py b/src/lexbench_webfetch/risk_control_dataset.py
index 16dbd0a..54333fd 100644
--- a/src/lexbench_webfetch/risk_control_dataset.py
+++ b/src/lexbench_webfetch/risk_control_dataset.py
@@ -53,6 +53,8 @@ def assign_content_acceptance_roles(cases: list[dict]) -> None:
and case["composite_acceptance"].get("groups")
)
or contract.get("page_patterns")
+ or (type(contract.get("minimum_records")) is int
+ and contract["minimum_records"] > 0)
):
raise ValueError(
"Diagnostic stable text requires body, composite, or page-pattern acceptance"
diff --git a/src/lexbench_webfetch/scoring.py b/src/lexbench_webfetch/scoring.py
index 3fafbfd..5b17e5d 100644
--- a/src/lexbench_webfetch/scoring.py
+++ b/src/lexbench_webfetch/scoring.py
@@ -160,26 +160,21 @@ def score_attempt(
))
result.update(request_ok=ok, request_status_basis=status_basis,
normalized_named_link_count=len(links))
- dynamic_page_identity = bool(
- case.get("track") == "dynamic"
- and case.get("evaluation_url")
- and case.get("expected_final_url")
- and case.get("target_language")
- and case.get("target_region")
+ language_page_identity = bool(
+ case.get("expected_document_language") or case.get("target_language")
+ or case.get("expected_document_language_present") is False
)
risk_navigation = (
((case.get("source_benchmark") or {}).get("risk_control") or {}).get(
"navigation_contract"
)
- if case.get("track") == "risk_control"
- else None
)
risk_page_identity = bool(
isinstance(risk_navigation, dict)
and isinstance(risk_navigation.get("requested_url"), str)
and isinstance(risk_navigation.get("observed_final_url"), str)
)
- if dynamic_page_identity:
+ if language_page_identity:
observed_language = (metadata or {}).get("document_language")
language_present = case.get("expected_document_language_present", True)
if not isinstance(language_present, bool):
@@ -187,11 +182,11 @@ def score_attempt(
if not language_present and case.get("expected_document_language"):
raise ValueError("An absent source language cannot have an expected language tag")
expected_document_language = (
- case.get("expected_document_language") or case["target_language"]
+ case.get("expected_document_language") or case.get("target_language")
)
final_consistent = bool(
isinstance(final_url, str) and final_url
- and _same_page_url(final_url, case["expected_final_url"])
+ and _same_page_url(final_url, case.get("expected_final_url") or case["url"])
)
language_consistent = (
observed_language == "" if not language_present else bool(
@@ -256,10 +251,10 @@ def score_attempt(
declared_regions
and len(measured_regions) == len(declared_regions)
and all(region.get("coverage") == 1.0 for region in measured_regions)
- and result.get("content_pass")
- and (not case.get("acceptance_contract") or result.get("stable_task_pass"))
+ and result.get("body_coverage_pass")
)
- if (result["task_kind"] == "body" and result["declared_truncation_hits"]
+ if ((result["task_kind"] == "body" or declared_regions)
+ and result["declared_truncation_hits"]
and not exact_body_proven):
result["content_pass"] = False
if contract := case.get("acceptance_contract"):
@@ -267,6 +262,9 @@ def score_attempt(
result["stable_content_pass"] = False
if "declared_truncation" not in result["stable_task_errors"]:
result["stable_task_errors"].append("declared_truncation")
+ result['body_delivery_pass'] = bool(
+ result['body_coverage_pass']
+ and (not result['declared_truncation_hits'] or exact_body_proven))
file_evaluation = (metadata or {}).get('file_evaluation')
if result['task_kind'] == 'file' and isinstance(file_evaluation, dict) and ('raw_file_pass' not in result or delivered_files is not None):
result['raw_file_pass'] = bool(file_evaluation.get('pass'))
@@ -340,8 +338,47 @@ def score_attempt(
# A threshold is only an explicit task requirement, not a benchmark-wide
# interpretation of continuous similarity as a pass rate.
if 'ordered_tree_min_similarity' in case:
+ result['ordered_tree_pass'] = bool(
+ tree_result['score'] >= case['ordered_tree_min_similarity'])
result['content_pass'] = bool(
- ok and tree_result['score'] >= case['ordered_tree_min_similarity']
+ result['content_pass'] and ok and result['ordered_tree_pass']
and not result['forbidden_requirement_hits'])
+ result["acceptance_checks"] = acceptance_checks(case, result)
result["scoring_source_sha256"] = SCORING_SOURCE_SHA256
return result
+
+
+def acceptance_checks(case: dict, metrics: dict) -> dict[str, bool | None]:
+ """Select declared requirements, never a website- or provider-specific scorer.
+
+ Missing evidence for a selected requirement remains unavailable. Undeclared
+ requirements are omitted; similarity diagnostics never become pass gates.
+ """
+ kind = case.get('task_kind', 'body')
+ checks = {}
+ if case.get('acceptance_contract'):
+ checks['records_and_anchors'] = metrics.get('stable_task_pass')
+ elif (case.get('source_benchmark') or {}).get('mode') == 'dynamic_list_structure':
+ checks['records'] = metrics.get('list_task_pass')
+ elif kind == 'body':
+ checks['content'] = metrics.get('content_pass')
+ if case.get('content_score_applicable', True):
+ if case.get('expected_any'):
+ checks['literals'] = metrics.get('legacy_marker_pass')
+ checks['length'] = metrics.get('content_length_pass')
+ if case.get('required_facts'):
+ checks['facts'] = metrics.get('fact_coverage_pass')
+ if case.get('required_patterns'):
+ checks['patterns'] = metrics.get('pattern_pass')
+ if case.get('body_requirements'):
+ checks['body'] = metrics.get('body_delivery_pass')
+ if case.get('composite_acceptance'):
+ checks['composite'] = metrics.get('composite_acceptance_pass')
+ if case.get('expected_resources') or case.get('resource_output_contract') or kind == 'resource':
+ checks['resources'] = metrics.get('resource_pass')
+ if kind == 'file':
+ checks['file'] = metrics.get('raw_file_pass')
+ if 'ordered_tree_min_similarity' in case:
+ checks['tree'] = metrics.get('ordered_tree_pass')
+ checks.update(metrics.get('required_components', {}))
+ return checks
diff --git a/src/lexbench_webfetch/stable_tasks.py b/src/lexbench_webfetch/stable_tasks.py
index eaa0744..f40a603 100644
--- a/src/lexbench_webfetch/stable_tasks.py
+++ b/src/lexbench_webfetch/stable_tasks.py
@@ -845,8 +845,6 @@ def validate_acceptance_contract(contract: dict) -> None:
raise ValueError("Unknown stable acceptance contract fields")
if contract.get("stable_text_role", "required") not in {"required", "diagnostic"}:
raise ValueError("Invalid stable text role")
- if contract.get("stable_text_role") == "diagnostic" and contract["schema"] != "body_text":
- raise ValueError("Diagnostic stable text requires a body-text contract")
if contract.get('content_scope', 'legacy') not in {'legacy', 'target_content', 'full_page_stable_coverage'}:
raise ValueError('Invalid stable content scope')
if contract.get('content_scope') == 'target_content' and any(
diff --git a/tests/test_acceptance_isolation.py b/tests/test_acceptance_isolation.py
index f917b5e..337dd52 100644
--- a/tests/test_acceptance_isolation.py
+++ b/tests/test_acceptance_isolation.py
@@ -118,7 +118,7 @@ def test_xml_declarations_cannot_crash_body_or_resource_scoring(declaration):
body_case = {"url": "https://example.test/spec", "expected_any": ["Specification"]}
raw = ("Specification\n" + declaration).encode()
assert task_passed(True, score_attempt(body_case, raw, ok=True))
- resource_case = {**body_case, "task_kind": "resource",
+ resource_case = {**body_case, "task_kind": "resource", "content_score_applicable": False,
"expected_resources": [{"id": "image", "urls": ["/target.png"]}]}
for answer, passed in [(declaration + '\n\n'
+ '[Target](/target.png)', True), (declaration + '\n', False)]:
diff --git a/tests/test_article_post_coverage_integration.py b/tests/test_article_post_coverage_integration.py
index 75fa610..a45f16d 100644
--- a/tests/test_article_post_coverage_integration.py
+++ b/tests/test_article_post_coverage_integration.py
@@ -25,12 +25,16 @@ def test_current_collection_coverage_without_runtime_reference(number, minimum):
assert case['source_benchmark']['post_contract']['minimum_posts'] == minimum
full = evaluate_response(case, (stable+'\n\n'+'\n\n'.join(posts)).encode())
assert full['passed'] and full['measured']
+ assert full['metrics']['acceptance_checks']['article_posts'] is True
assert 'article_reference_status' not in full['metrics']
assert full['metrics']['content_decision_basis'] == 'base_acceptance_and_required_components'
- for text in (stable, stable+'\n\n'+'\n\n'.join(posts[:-1]), '\n\n'.join(posts)):
+ for text, posts_pass in ((stable, False),
+ (stable+'\n\n'+'\n\n'.join(posts[:-1]), False),
+ ('\n\n'.join(posts), True)):
result = evaluate_response(case, text.encode())
assert not result['passed']
assert result['measured']
+ assert result['metrics']['acceptance_checks']['article_posts'] is posts_pass
def test_cli_accepts_collection_without_reference_arguments(tmp_path):
diff --git a/tests/test_body_text_acceptance_roles.py b/tests/test_body_text_acceptance_roles.py
index 399c21b..7d948e3 100644
--- a/tests/test_body_text_acceptance_roles.py
+++ b/tests/test_body_text_acceptance_roles.py
@@ -84,19 +84,6 @@ def test_empty_composite_owner_cannot_make_stable_text_diagnostic():
assign_content_acceptance_roles([case])
-def test_structured_contract_cannot_relax_its_stable_text():
- contract = {
- "version": 1,
- "schema": "named_link",
- "minimum_records": 1,
- "hosts": ["example.test"],
- "entry_path": "/item/[0-9]+",
- "stable_text_role": "diagnostic",
- }
- with pytest.raises(ValueError, match="body-text"):
- validate_acceptance_contract(contract)
-
-
def test_compiler_policy_does_not_infer_diagnostic_from_gate_presence():
body = body_case()
del body["acceptance_contract"]["stable_text_role"]
diff --git a/tests/test_stable_tasks.py b/tests/test_stable_tasks.py
index e9a1768..4324362 100644
--- a/tests/test_stable_tasks.py
+++ b/tests/test_stable_tasks.py
@@ -467,16 +467,16 @@ def test_strict_contract_validation_before_snapshot_exemption(self):
with self.subTest(value=value), self.assertRaises(ValueError):
validate_acceptance_contract(value)
- def test_contract_metrics_decision_is_independent_of_frozen_literal_metrics(self):
+ def test_declared_facts_remain_required_with_a_record_contract(self):
rules = contract("product")
before = deepcopy(rules)
result = evaluate_content("Alpha\n$10\n\nBeta\n$20", ["Old name"], required_facts=[
{"id": "frozen", "alternatives": ["Old title $50"]}], min_content_chars=10000,
ground_truth_main_content="Old title $50", acceptance_contract=rules)
- self.assertTrue(result["content_pass"])
+ self.assertFalse(result["content_pass"])
self.assertEqual(result["fact_coverage"], 0)
self.assertIsNotNone(result["word_f1"])
- self.assertTrue(task_passed(True, result, "structure"))
+ self.assertFalse(task_passed(True, result, "structure"))
self.assertFalse(task_passed(False, result))
self.assertEqual(rules, before)
self.assertIsNone(result["factual_correctness"])
@@ -493,7 +493,7 @@ def test_stable_text_and_structure_are_separate_from_word_overlap(self):
{"id": "section", "alternatives": ["Product catalog", "Available products"]},
{"id": "explanation", "alternatives": ["Prices include tax"]}])
text = "# Available products\nPrices include tax\n\nNew coat\n$19\n\nNew scarf\n$99"
- result = evaluate_content(text, ["Old title"], required_facts=[{"id": "old", "alternatives": ["Old price"]}],
+ result = evaluate_content(text, [],
ground_truth_main_content="Old title Old price", acceptance_contract=rules)
self.assertTrue(result["stable_structure_pass"])
self.assertTrue(result["stable_content_pass"])
diff --git a/tests/test_unified_requirements.py b/tests/test_unified_requirements.py
new file mode 100644
index 0000000..5836e69
--- /dev/null
+++ b/tests/test_unified_requirements.py
@@ -0,0 +1,225 @@
+"""Declared requirements compose independently of a task's reporting family."""
+import pytest
+from lexbench_webfetch.scoring import score_attempt
+from lexbench_webfetch.metrics import task_passed
+
+
+CONTRACT = {'version': 1, 'schema': 'named_link', 'minimum_records': 1,
+ 'hosts': ['example.test'], 'entry_path': r'/item/\d+',
+ 'stable_text_anchors': []}
+
+
+@pytest.mark.parametrize('kind', ['body', 'structure', 'resource'])
+@pytest.mark.parametrize('missing', [None, 'fact', 'resource', 'record'])
+def test_every_declared_requirement_contributes_to_acceptance(kind, missing):
+ case = {'url': 'https://example.test/', 'task_kind': kind,
+ 'acceptance_contract': CONTRACT,
+ 'required_facts': [{'id': 'description', 'alternatives': ['Battery lasts twelve hours']}],
+ 'expected_resources': [{'id': 'manual', 'urls': ['/manual.pdf']}]}
+ parts = {'fact': 'Battery lasts twelve hours', 'record': '[Portable speaker](/item/1)',
+ 'resource': '[Manual](/manual.pdf)'}
+ answer = '\n'.join(text for key, text in parts.items() if key != missing)
+ metrics = score_attempt(case, answer.encode(), ok=True)
+ assert task_passed(True, metrics) is (missing is None)
+
+
+@pytest.mark.parametrize('answer,passed', [
+ ('Home\n[Item](/item/1)\nBattery lasts twelve hours', True),
+ ('Home\n[Item](/item/1)', False)])
+def test_required_facts_remain_required_next_to_a_stable_contract(answer, passed):
+ case = {'url': 'https://example.test/', 'acceptance_contract': CONTRACT,
+ 'required_facts': [{'id': 'detail', 'alternatives': ['Battery lasts twelve hours']}]}
+ assert task_passed(True, score_attempt(case, answer.encode(), ok=True)) is passed
+
+
+@pytest.mark.parametrize('kind', ['body', 'structure', 'resource', 'file'])
+@pytest.mark.parametrize('missing', [None, 'fact', 'resource', 'record', 'file'])
+def test_file_and_text_requirements_use_the_same_combination(kind, missing):
+ from lexbench_webfetch.evaluation import evaluate_response
+ case = {'url': 'https://example.test/', 'task_kind': kind,
+ 'acceptance_contract': CONTRACT,
+ 'required_facts': [{'id': 'description', 'alternatives': ['Battery lasts twelve hours']}],
+ 'expected_resources': [{'id': 'manual', 'urls': ['/manual.pdf']}]}
+ parts = {'fact': 'Battery lasts twelve hours', 'record': '[Portable speaker](/item/1)',
+ 'resource': '[Manual](/manual.pdf)'}
+ answer = '\n'.join(text for key, text in parts.items() if key != missing).encode()
+ files = [] if missing == 'file' else [b'\x89PNG\r\n\x1a\n' + b'x' * 32]
+ result = evaluate_response(case, answer, files=files)
+ assert result['passed'] is (missing is None or (missing == 'file' and kind != 'file'))
+
+
+def test_absent_requirements_are_skipped_but_missing_required_evidence_is_not():
+ from lexbench_webfetch.evaluation import evaluate_response
+ task = {'url': 'https://example.test/', 'task_kind': 'resource'}
+ unavailable = evaluate_response(task, b'No reference or resource returned')
+ assert not unavailable['passed']
+ assert not unavailable['measured']
+ assert unavailable['metrics']['acceptance_checks']['resources'] is None
+ task = {'url': 'https://example.test/', 'required_facts': [
+ {'id': 'body', 'alternatives': ['Article body']} ]}
+ accepted = evaluate_response(task, b'Article body')
+ assert accepted['passed']
+ assert 'facts' in accepted['metrics']['acceptance_checks']
+ assert 'resources' not in accepted['metrics']['acceptance_checks']
+ assert 'file' not in accepted['metrics']['acceptance_checks']
+
+
+def test_perfect_tree_does_not_override_a_missing_content_requirement():
+ import json
+ case = {'url': 'https://example.test/', 'ordered_tree_reference': {'tag': 'table'},
+ 'ordered_tree_min_similarity': 1.0,
+ 'required_facts': [{'id': 'value', 'alternatives': ['Quarterly revenue']} ]}
+ metrics = score_attempt(case, json.dumps(case['ordered_tree_reference']).encode(), ok=True)
+ assert metrics['ordered_tree_similarity'] == 1.0
+ assert not task_passed(True, metrics)
+
+
+@pytest.mark.parametrize('field,value,short_answer', [
+ ('expected_any', ['Required introduction'], '[Item](/item/1)'),
+ ('min_content_chars', 80, '[Item](/item/1)')])
+def test_declared_literal_and_length_requirements_survive_a_record_contract(field, value, short_answer):
+ case = {'url': 'https://example.test/', 'acceptance_contract': CONTRACT, field: value}
+ assert not task_passed(True, score_attempt(case, short_answer.encode(), ok=True))
+ full = 'Required introduction with a full substantive explanation of the portable device.\n[Item](/item/1)'
+ assert task_passed(True, score_attempt(case, full.encode(), ok=True))
+
+
+@pytest.mark.parametrize('track', ['web', 'dynamic', 'challenge', 'risk_control', 'document', 'image', 'video'])
+@pytest.mark.parametrize('language,passed', [('en', True), ('fr', False), (None, False)])
+def test_declared_document_language_check_does_not_depend_on_track(track, language, passed):
+ case = {'url': 'https://example.test/', 'track': track,
+ 'expected_final_url': 'https://example.test/',
+ 'expected_document_language': 'en', 'page_identity_required': True}
+ metrics = score_attempt(case, b'Article body', ok=True, final_url=case['url'],
+ metadata={'document_language': language})
+ assert task_passed(True, metrics) is passed
+
+
+@pytest.mark.parametrize('kind', ['body', 'structure', 'resource', 'file'])
+@pytest.mark.parametrize('complete', [True, False])
+def test_declared_body_scope_keeps_truncation_protection_across_output_types(kind, complete):
+ from lexbench_webfetch.evaluation import evaluate_response
+ task = {'url': 'https://example.test/', 'task_kind': kind,
+ 'acceptance_contract': CONTRACT,
+ 'expected_resources': [{'id': 'image', 'urls': ['/image.png']}],
+ 'body_requirements': {'version': 1, 'unit': 'region_token_recall', 'minimum_coverage': 0.8,
+ 'regions': [{'id': 'body', 'text': 'alpha beta gamma delta epsilon zeta eta theta iota kappa'}]}}
+ answer = ('alpha beta gamma delta epsilon zeta eta theta iota\n'
+ '[Item](/item/1)\n\n'
+ '(Content truncated. Fetch a more specific URL or section for the full text.)')
+ if complete:
+ answer = answer.replace('theta iota\n', 'theta iota kappa\n')
+ result = evaluate_response(task, answer.encode(), files=[b'\x89PNG\r\n\x1a\n'+b'x'*32])
+ assert result['passed'] is complete
+
+
+@pytest.mark.parametrize('track', ['web', 'dynamic', 'document'])
+@pytest.mark.parametrize('language,passed', [('', True), ('en', False), (None, False)])
+def test_declared_absent_language_tag_is_checked_without_locale_or_redirect_fields(track, language, passed):
+ case = {'url': 'https://example.test/', 'track': track,
+ 'expected_document_language_present': False, 'page_identity_required': True}
+ metrics = score_attempt(case, b'Article body', ok=True, final_url=case['url'],
+ metadata={'document_language': language})
+ assert task_passed(True, metrics) is passed
+
+
+def test_dict_and_dataclass_apply_the_same_default_text_requirement():
+ from lexbench_webfetch.models import EvalCase
+ links = {'links': [{'text': 'Portable speaker', 'href': '/item/1'}]}
+ common = {'id': 'sample', 'name': 'Sample', 'url': 'https://example.test/',
+ 'category': 'test', 'expected_any': [], 'task_kind': 'structure',
+ 'acceptance_contract': CONTRACT}
+ for applicable, passed in [(True, False), (False, True)]:
+ task = {**common, 'content_score_applicable': applicable}
+ for case in (task, EvalCase(**task)):
+ assert task_passed(True, score_attempt(case, b'', ok=True, metadata=links)) is passed
+
+
+def test_low_level_content_checks_keep_declared_literals_and_length():
+ from lexbench_webfetch.metrics import evaluate_content
+ assert evaluate_content('[Portable speaker](/item/1)', [], acceptance_contract=CONTRACT,
+ base_url='https://example.test/')['content_pass']
+ for fields in ({'expected': ['Required introduction']}, {'min_content_chars': 1000}):
+ arguments = {'expected': [], **fields}
+ metrics = evaluate_content('[Portable speaker](/item/1)', acceptance_contract=CONTRACT,
+ base_url='https://example.test/', **arguments)
+ assert not metrics['content_pass']
+
+
+def test_omitted_literal_check_cannot_invalidate_proven_complete_body():
+ from lexbench_webfetch.evaluation import evaluate_response
+ case = {'url': 'https://example.test/', 'acceptance_contract': CONTRACT,
+ 'content_score_applicable': False, 'expected_any': ['Optional page shell'],
+ 'body_requirements': {'version': 1, 'unit': 'region_token_recall',
+ 'minimum_coverage': 0.8, 'regions': [{'id': 'body', 'text': 'alpha beta gamma delta epsilon'}]}}
+ result = evaluate_response(case, b'alpha beta gamma delta epsilon\n[Speaker](/item/1)\n(Content truncated. Fetch a more specific URL or section for the full text.)')
+ assert result['passed']
+
+
+def test_frozen_metric_projection_keeps_its_recorded_acceptance_semantics():
+ frozen = {'scoring_source_sha256': 'a' * 64, 'stable_task_pass': True,
+ 'content_pass': False, 'content_score_applicable': False,
+ 'stable_anchor_results': [{'pass': True}]}
+ assert task_passed(True, frozen)
+ # A failed declared pattern remained a failure in the historical format.
+ assert not task_passed(True, {**frozen, 'pattern_results': [{'pass': False}]})
+ current = score_attempt({'url': 'https://example.test/',
+ 'acceptance_contract': CONTRACT, 'required_facts': [
+ {'id': 'required', 'alternatives': ['Battery lasts twelve hours']}]},
+ b'[Speaker](/item/1)', ok=True)
+ assert not task_passed(True, current)
+ assert 'acceptance_checks' not in frozen
+
+
+@pytest.mark.parametrize('answer,passed', [
+ ('[Speaker](/item/1)', True), ('Account Privacy Terms', False)])
+def test_page_shell_literals_can_be_diagnostic_when_records_own_acceptance(answer, passed):
+ case = {'url': 'https://example.test/', 'acceptance_contract': {
+ **CONTRACT, 'stable_text_role': 'diagnostic',
+ 'stable_text_anchors': [{'id': 'shell', 'alternatives': ['Account Privacy Terms']}]}}
+ assert task_passed(True, score_attempt(case, answer.encode(), ok=True)) is passed
+
+
+def test_imported_tasks_apply_the_same_record_owned_acceptance_role():
+ from lexbench_webfetch.risk_control_dataset import assign_content_acceptance_roles
+ case = {'acceptance_contract': {**CONTRACT, 'stable_text_role': 'diagnostic'}}
+ assign_content_acceptance_roles([case])
+ assert case['acceptance_contract']['stable_text_role'] == 'diagnostic'
+
+
+@pytest.mark.parametrize('kind', ['body', 'structure'])
+@pytest.mark.parametrize('answer,passed', [('Battery lasts twelve hours', True), ('Home Terms', False)])
+def test_facts_do_not_require_an_undeclared_record_check(kind, answer, passed):
+ case = {'url': 'https://example.test/', 'task_kind': kind,
+ 'required_facts': [{'id': 'duration', 'alternatives': ['Battery lasts twelve hours']}]}
+ result = score_attempt(case, answer.encode(), ok=True)
+ assert task_passed(True, result) is passed
+ assert 'records' not in result['acceptance_checks']
+
+
+@pytest.mark.parametrize('track', ['web', 'dynamic', 'challenge', 'risk_control', 'articles', 'document'])
+@pytest.mark.parametrize('kind', ['body', 'structure', 'resource', 'file'])
+@pytest.mark.parametrize('missing', [None, 'title', 'comments'])
+def test_optional_content_components_share_the_declared_acceptance_inventory(track, kind, missing):
+ from article_contract_fixtures import comment_labels
+ from lexbench_webfetch.evaluation import evaluate_response
+ case = {
+ 'url': 'https://example.test/', 'track': track, 'task_kind': kind,
+ 'min_content_chars': 1, 'acceptance_contract': CONTRACT,
+ 'expected_resources': [{'id': 'manual', 'urls': ['/manual.pdf']}],
+ 'source_benchmark': {
+ 'title_contract': {'version': 1, 'alternatives': ['Why We Read'], 'html_selector': 'h1'},
+ 'comment_contract': {**comment_labels(), 'version': 2, 'record_selector': '.comment',
+ 'author_selector': '.author', 'body_selector': '.text',
+ 'section_markers': ['Comments'], 'minimum_comments': 1},
+ },
+ }
+ title = '# Another article' if missing == 'title' else '# Why We Read'
+ answer = title + '\n\nArticle body.\n\n[Portable speaker](/item/1)\n\n[Manual](/manual.pdf)'
+ if missing != 'comments':
+ answer += '\n\nComments\n\n## Reader\nA thoughtful book.'
+ result = evaluate_response(case, answer.encode(), files=[b'\x89PNG\r\n\x1a\n' + b'x' * 32])
+ checks = result['metrics']['acceptance_checks']
+ assert checks['article_title'] is (missing != 'title')
+ assert checks['article_comments'] is (missing != 'comments')
+ assert result['passed'] is (missing is None)