Skip to content

Commit b4a6aa2

Browse files
week_2: Module C C.0 — section validator (input boundary) + ExplicitLinkResolver
The C.0 deterministic input boundary, per the proposal's W2/W3 'data preparation layer' (named validator, not normalizer: the RFC assigns text normalization to Module A; C validates and adapts, never transforms text). - section_validator.py: typed-error validation of both upstream shapes (B's knowledge_queue row + RFC KnowledgeItem envelope) into an internal Section; synthesizes RFC identity fields (chunk_id/artifact_id/source/ locator) from B's reduced row; strips volatile audit metadata. - explicit_link_resolver.py: deterministic ddd-ddd fast path, no ML. Fail-safe: only a single known reference auto-links; unknown or conflicting references route to review. - evaluate_librarian.py: harness now runs every golden row through C.0, prints per-slice validation pass rate, and gates the explicit slice at 100% resolver correctness (exit 1 on regression). Gate: PASS 5/5. - Table-driven tests for every rejection class and resolver outcome. mypy --strict clean, black clean, 66 tests green.
1 parent e097016 commit b4a6aa2

5 files changed

Lines changed: 612 additions & 18 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for the C.0.5 deterministic explicit-CRE fast path.
2+
3+
Covers extraction (pattern boundaries, ordering, dedup) and resolution
4+
(the fail-safe rule: only a single known reference auto-links; unknown or
5+
conflicting references must fall through to review).
6+
"""
7+
8+
import unittest
9+
10+
from application.utils.librarian.explicit_link_resolver import (
11+
Resolution,
12+
ResolutionOutcome,
13+
extract_cre_refs,
14+
resolve,
15+
)
16+
17+
KNOWN = {"027-555", "123-456", "764-507"}
18+
19+
20+
class ExtractCreRefsTest(unittest.TestCase):
21+
def test_extraction_table(self) -> None:
22+
cases = [
23+
("plain reference", "Per CRE 027-555, verify passwords.", ["027-555"]),
24+
(
25+
"inside an opencre url",
26+
"See https://opencre.org/cre/123-456 for details.",
27+
["123-456"],
28+
),
29+
(
30+
"multiple distinct, in order",
31+
"Maps to 123-456 and also 027-555.",
32+
["123-456", "027-555"],
33+
),
34+
("repeated reference deduped", "027-555 then 027-555 again.", ["027-555"]),
35+
("no reference", "Use MFA for all administrative access.", []),
36+
("too many leading digits", "CVE-2024-1234-567 is unrelated.", []),
37+
("too many trailing digits", "Item 027-5555 is not a CRE id.", []),
38+
("punctuation boundary", "(see CRE 764-507).", ["764-507"]),
39+
]
40+
for name, text, expected in cases:
41+
with self.subTest(name):
42+
self.assertEqual(extract_cre_refs(text), expected)
43+
44+
45+
class ResolveTest(unittest.TestCase):
46+
def test_single_known_reference_resolves(self) -> None:
47+
resolution = resolve("Per CRE 027-555, verify passwords.", KNOWN)
48+
self.assertEqual(
49+
resolution,
50+
Resolution(ResolutionOutcome.resolved, ("027-555",), ()),
51+
)
52+
53+
def test_no_reference_falls_through_to_semantic_path(self) -> None:
54+
resolution = resolve("Use MFA for all administrative access.", KNOWN)
55+
self.assertEqual(resolution.outcome, ResolutionOutcome.no_reference)
56+
self.assertEqual(resolution.cre_ids, ())
57+
58+
def test_unknown_reference_never_auto_links(self) -> None:
59+
resolution = resolve("Per CRE 999-999, do the thing.", KNOWN)
60+
self.assertEqual(resolution.outcome, ResolutionOutcome.unknown_reference)
61+
self.assertEqual(resolution.cre_ids, ())
62+
self.assertEqual(resolution.unknown_refs, ("999-999",))
63+
64+
def test_mixed_known_and_unknown_routes_to_review(self) -> None:
65+
resolution = resolve("Maps to 027-555 and 999-999.", KNOWN)
66+
self.assertEqual(resolution.outcome, ResolutionOutcome.unknown_reference)
67+
# The known id survives as a review suggestion, but must not auto-link.
68+
self.assertEqual(resolution.cre_ids, ("027-555",))
69+
self.assertEqual(resolution.unknown_refs, ("999-999",))
70+
71+
def test_conflicting_known_references_route_to_review(self) -> None:
72+
resolution = resolve("Maps to 123-456 and also 027-555.", KNOWN)
73+
self.assertEqual(resolution.outcome, ResolutionOutcome.conflicting_references)
74+
self.assertEqual(resolution.cre_ids, ("123-456", "027-555"))
75+
76+
def test_repeated_single_reference_still_resolves(self) -> None:
77+
resolution = resolve("027-555 is cited twice: 027-555.", KNOWN)
78+
self.assertEqual(resolution.outcome, ResolutionOutcome.resolved)
79+
self.assertEqual(resolution.cre_ids, ("027-555",))
80+
81+
82+
if __name__ == "__main__":
83+
unittest.main()
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Tests for the C.0 input boundary (section_validator).
2+
3+
Table-driven over every rejection class plus the happy paths for both
4+
upstream shapes (knowledge_queue row and RFC KnowledgeItem envelope).
5+
Asserts the boundary never leaks a raw Pydantic ValidationError.
6+
"""
7+
8+
import unittest
9+
10+
from pydantic import ValidationError
11+
12+
from application.utils.librarian.section_validator import (
13+
EmptyTextError,
14+
MalformedKnowledgeItemError,
15+
NotKnowledgeError,
16+
Section,
17+
SectionValidationError,
18+
UnsupportedLanguageError,
19+
section_from_knowledge_item,
20+
section_from_queue_row,
21+
)
22+
23+
24+
def valid_queue_row(**overrides) -> dict:
25+
row = {
26+
"id": "4a8c1b2e-1d2f-4e3a-9b4c-5d6e7f8a9b0c",
27+
"source_repo": "OWASP/ASVS",
28+
"source_path": "4.0/en/0x11-V2-Authentication.md",
29+
"source_commit_sha": "abc123def456789012345678901234567890abcd",
30+
"text": "Verify that user-set passwords are at least 12 characters long.",
31+
"confidence": 0.93,
32+
"llm_label": "KNOWLEDGE",
33+
"llm_reasoning": "clear security requirement",
34+
"created_at": "2026-05-25T02:25:00Z",
35+
"consumed_at": None,
36+
}
37+
row.update(overrides)
38+
return row
39+
40+
41+
def valid_knowledge_item(**overrides) -> dict:
42+
item = {
43+
"schema_version": "0.2.0",
44+
"chunk_id": "chk:art:OWASP/ASVS:4.0/en/0x11-V2-Authentication.md:0",
45+
"artifact_id": "art:OWASP/ASVS:4.0/en/0x11-V2-Authentication.md",
46+
"event_id": "evt-001",
47+
"pipeline_run_id": "20260601T020000Z",
48+
"filtered_at": "2026-06-01T02:10:00Z",
49+
"status": "accepted",
50+
"source": {
51+
"type": "github",
52+
"repo": "OWASP/ASVS",
53+
"commit_sha": "abc123def456789012345678901234567890abcd",
54+
"committed_at": "2026-06-01T01:00:00Z",
55+
},
56+
"locator": {
57+
"kind": "repo_path",
58+
"id": "4.0/en/0x11-V2-Authentication.md",
59+
"path": "4.0/en/0x11-V2-Authentication.md",
60+
},
61+
"content": {
62+
"text": "Verify that user-set passwords are at least 12 characters long.",
63+
"title_hint": "Password length",
64+
"language": "en",
65+
},
66+
"filter": {
67+
"stages": [{"name": "llm_relevance", "passed": True}],
68+
"is_security_knowledge": True,
69+
"confidence": 0.93,
70+
},
71+
}
72+
item.update(overrides)
73+
return item
74+
75+
76+
class QueueRowBoundaryTest(unittest.TestCase):
77+
def test_valid_row_builds_section_with_synthesized_identity(self) -> None:
78+
section = section_from_queue_row(valid_queue_row())
79+
self.assertIsInstance(section, Section)
80+
self.assertEqual(
81+
section.chunk_id,
82+
"chk:OWASP/ASVS@abc123def456789012345678901234567890abcd:"
83+
"4.0/en/0x11-V2-Authentication.md",
84+
)
85+
self.assertEqual(
86+
section.artifact_id, "art:OWASP/ASVS:4.0/en/0x11-V2-Authentication.md"
87+
)
88+
self.assertEqual(section.source.repo, "OWASP/ASVS")
89+
self.assertEqual(section.source.committed_at, "2026-05-25T02:25:00Z")
90+
self.assertEqual(section.locator.path, "4.0/en/0x11-V2-Authentication.md")
91+
self.assertEqual(section.language, "en")
92+
93+
def test_volatile_metadata_not_carried_into_section(self) -> None:
94+
section = section_from_queue_row(
95+
valid_queue_row(llm_reasoning="audit-only rationale")
96+
)
97+
self.assertFalse(hasattr(section, "llm_reasoning"))
98+
self.assertFalse(hasattr(section, "confidence"))
99+
100+
def test_rejection_table(self) -> None:
101+
cases = [
102+
("empty text", valid_queue_row(text=""), EmptyTextError),
103+
("whitespace text", valid_queue_row(text=" \n\t "), EmptyTextError),
104+
("noise label", valid_queue_row(llm_label="NOISE"), NotKnowledgeError),
105+
(
106+
"uncertain label",
107+
valid_queue_row(llm_label="UNCERTAIN"),
108+
NotKnowledgeError,
109+
),
110+
(
111+
"missing field",
112+
{k: v for k, v in valid_queue_row().items() if k != "source_repo"},
113+
MalformedKnowledgeItemError,
114+
),
115+
(
116+
"wrong type",
117+
valid_queue_row(confidence="very sure"),
118+
MalformedKnowledgeItemError,
119+
),
120+
("not a mapping", "just a string", MalformedKnowledgeItemError),
121+
]
122+
for name, row, expected_error in cases:
123+
with self.subTest(name):
124+
with self.assertRaises(expected_error):
125+
section_from_queue_row(row)
126+
127+
def test_never_leaks_raw_pydantic_error(self) -> None:
128+
try:
129+
section_from_queue_row({"id": "x"})
130+
except SectionValidationError as exc:
131+
self.assertNotIsInstance(exc, ValidationError)
132+
self.assertIsInstance(exc.__cause__, ValidationError)
133+
else:
134+
self.fail("expected SectionValidationError")
135+
136+
137+
class KnowledgeItemBoundaryTest(unittest.TestCase):
138+
def test_valid_item_builds_section(self) -> None:
139+
section = section_from_knowledge_item(valid_knowledge_item())
140+
self.assertEqual(
141+
section.chunk_id, "chk:art:OWASP/ASVS:4.0/en/0x11-V2-Authentication.md:0"
142+
)
143+
self.assertEqual(section.title_hint, "Password length")
144+
self.assertEqual(section.language, "en")
145+
146+
def test_missing_language_defaults_to_english(self) -> None:
147+
item = valid_knowledge_item()
148+
del item["content"]["language"]
149+
self.assertEqual(section_from_knowledge_item(item).language, "en")
150+
151+
def test_regional_english_variant_is_accepted(self) -> None:
152+
item = valid_knowledge_item()
153+
item["content"]["language"] = "en-GB"
154+
self.assertEqual(section_from_knowledge_item(item).language, "en-GB")
155+
156+
def test_rejection_table(self) -> None:
157+
rejected = valid_knowledge_item(
158+
status="rejected",
159+
content=None,
160+
rejection={"reason_code": "NOT_SECURITY"},
161+
)
162+
unsupported_lang = valid_knowledge_item()
163+
unsupported_lang["content"]["language"] = "fr"
164+
whitespace_text = valid_knowledge_item()
165+
whitespace_text["content"]["text"] = " "
166+
malformed = valid_knowledge_item()
167+
del malformed["source"]
168+
169+
cases = [
170+
("status rejected", rejected, NotKnowledgeError),
171+
("unsupported language", unsupported_lang, UnsupportedLanguageError),
172+
("whitespace text", whitespace_text, EmptyTextError),
173+
("missing source", malformed, MalformedKnowledgeItemError),
174+
]
175+
for name, item, expected_error in cases:
176+
with self.subTest(name):
177+
with self.assertRaises(expected_error):
178+
section_from_knowledge_item(item)
179+
180+
181+
if __name__ == "__main__":
182+
unittest.main()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Module C.0.5 — deterministic explicit-CRE fast path (Week 2). No ML.
2+
3+
If a section's text already cites a CRE id (``ddd-ddd``, plain or inside an
4+
``opencre.org/cre/<id>`` link), resolve it directly against the set of known
5+
CRE ids and bypass retrieval entirely. The fail-safe rule from the proposal:
6+
unknown or mutually conflicting references never auto-link — they fall
7+
through to human review; only a single, known reference resolves.
8+
9+
Gate (PR 2): 100% correctness on the explicit golden-dataset slice. Any
10+
regression here is a merge blocker.
11+
12+
The known-id set is injected (``Container[str]``) so this module stays
13+
dependency-free: the harness seeds it from the golden dataset today; the
14+
DB-backed registry of real ``cre.external_id`` values arrives with the
15+
retriever (W3).
16+
"""
17+
18+
import re
19+
from dataclasses import dataclass
20+
from enum import Enum
21+
from typing import Container, List, Tuple
22+
23+
# Word boundaries keep e.g. "1234-567" and "027-5555" from partially matching.
24+
CRE_ID_RE = re.compile(r"\b\d{3}-\d{3}\b")
25+
26+
27+
class ResolutionOutcome(str, Enum):
28+
# No CRE reference in the text — continue to the semantic path (C.1+).
29+
no_reference = "no_reference"
30+
# Exactly one known CRE id — deterministic auto-link, skip retrieval.
31+
resolved = "resolved"
32+
# Reference(s) found but none/some are known CRE ids — route to review.
33+
unknown_reference = "unknown_reference"
34+
# Multiple distinct known ids in one section — ambiguous, route to review.
35+
conflicting_references = "conflicting_references"
36+
37+
38+
@dataclass(frozen=True)
39+
class Resolution:
40+
outcome: ResolutionOutcome
41+
# Known ids, deduped, in order of first appearance. Non-empty iff some
42+
# reference resolved; for conflicting outcomes these become the
43+
# ReviewItem's suggested_links.
44+
cre_ids: Tuple[str, ...]
45+
# References that matched the CRE-id pattern but are not known ids.
46+
unknown_refs: Tuple[str, ...]
47+
48+
49+
def extract_cre_refs(text: str) -> List[str]:
50+
"""All CRE-id-shaped references in the text, deduped, in order."""
51+
seen = set()
52+
refs = []
53+
for match in CRE_ID_RE.findall(text):
54+
if match not in seen:
55+
seen.add(match)
56+
refs.append(match)
57+
return refs
58+
59+
60+
def resolve(text: str, known_cre_ids: Container[str]) -> Resolution:
61+
"""Deterministically resolve explicit CRE references in one section."""
62+
refs = extract_cre_refs(text)
63+
if not refs:
64+
return Resolution(ResolutionOutcome.no_reference, (), ())
65+
66+
known = tuple(ref for ref in refs if ref in known_cre_ids)
67+
unknown = tuple(ref for ref in refs if ref not in known_cre_ids)
68+
69+
if unknown:
70+
return Resolution(ResolutionOutcome.unknown_reference, known, unknown)
71+
if len(known) > 1:
72+
return Resolution(ResolutionOutcome.conflicting_references, known, ())
73+
return Resolution(ResolutionOutcome.resolved, known, ())

0 commit comments

Comments
 (0)