From fd52f4dad7518061974e4e1635347aee745ec5dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:12:00 +0900 Subject: [PATCH 1/9] fix: require complete organization token corroboration --- docs/adr/0005-relation-verification-agent.md | 12 ++++--- lineageweave/relation_verification.py | 28 ++++++++++------- tests/test_relation_verification.py | 33 ++++++++++++++++++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/docs/adr/0005-relation-verification-agent.md b/docs/adr/0005-relation-verification-agent.md index ed5bd8108..1570d29d6 100644 --- a/docs/adr/0005-relation-verification-agent.md +++ b/docs/adr/0005-relation-verification-agent.md @@ -38,10 +38,14 @@ observed from LLM classification), not "this specific relationship claim is definitely true" -- a genuinely false relationship between two REAL organizations still returns results about each organization separately, so this is an existence/plausibility check, not a full -relationship-truth adjudicator. That is a real upgrade path once real -usage shows the coarser signal under- or over-trusting results in -practice, not implemented here because nothing yet demonstrates the -need for it over this cheaper stage. +relationship-truth adjudicator. A result is accepted only when every +distinctive token in the proposed organization name occurs in the +result's non-search host or content snippet; the result title is excluded +because search engines echo the query there. This prevents one generic +word in an unrelated result from validating an invented multi-token name. +That is a real upgrade path once real usage shows the coarser signal under- +or over-trusting results in practice, not implemented here because +nothing yet demonstrates the need for full NLI over this cheaper stage. The real implementation, `SearxngRelationVerificationClient`, queries a **self-hosted** Searxng instance (`docker/searxng/`), never a diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index acba7b225..507336b72 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -117,15 +117,18 @@ class SearxngRelationVerificationClient: """Queries a self-hosted Searxng instance's JSON API for corroborating evidence of a claimed organization/relationship. - The presence/absence signal is deliberately coarse: any search result - for "```` ````" is treated as - corroboration that the named organization has a real-world footprint - consistent with the claim, not proof the specific relationship is - true (a genuinely false relationship between two REAL organizations - would still return results about each organization separately). This - catches the failure mode actually observed from LLM classification -- - an invented organization name with zero web footprint -- rather than - claiming to adjudicate relationship truth from search snippets alone. + The presence/absence signal is deliberately coarse: a result whose + host or snippet contains every distinctive token in the organization + name is treated as corroboration that the named organization has a + real-world footprint consistent with the claim, not proof the specific + relationship is true (a genuinely false relationship between two REAL + organizations would still return results about each organization + separately). Requiring every token prevents an unrelated page that + happens to contain one common word from corroborating an invented name. + This catches the failure mode actually observed from LLM + classification -- an invented organization name with zero web + footprint -- rather than claiming to adjudicate relationship truth from + search snippets alone. """ available = True @@ -159,9 +162,10 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - """Return ``result['url']`` when it is a real-world footprint of ``organization_name``. Search engines echo the query in result titles, so "any hit" is not - corroboration. A result counts only when a distinctive name token + corroboration. A result counts only when every distinctive name token appears in the host or snippet, and the host is not itself a search - page. Missing or empty URLs are not evidence. + page. The title is intentionally excluded because search engines echo + the query there. Missing or empty URLs are not evidence. """ url = result.get("url") if not isinstance(url, str) or not url.strip(): @@ -177,6 +181,6 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - if not tokens: return None haystack = f"{host} {result.get('content') or ''}".lower() - if any(token in haystack for token in tokens): + if all(token in haystack for token in tokens): return url return None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..f0a9b0c26 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -29,7 +29,7 @@ class _ResultsHandler(BaseHTTPRequestHandler): received_query: str = "" - def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + def do_GET(self) -> None: parsed = urlparse(self.path) query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] @@ -53,7 +53,7 @@ def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + def log_message(self, format: str, *args) -> None: return @@ -122,6 +122,35 @@ def test_org_token_in_result_host_is_corroboration() -> None: ) +def test_partial_multi_token_name_is_not_corroboration() -> None: + """One generic token must not validate an invented multi-token name.""" + assert ( + corroborating_evidence_url( + "Fictitious Nonexistent Org", + { + "url": "https://microsoft.example/news", + "title": "Fictitious names, domains, and addresses", + "content": "This page discusses fictitious names.", + }, + ) + is None + ) + + +def test_all_distinctive_multi_token_name_parts_are_corroboration() -> None: + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://aurora-grid.example/news", + "title": "Aurora Grid Power", + "content": "Aurora Grid Power announced a delivery window.", + }, + ) + == "https://aurora-grid.example/news" + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( From 3b9fe2e87750da5e1b4efcd4c3335dae6ec5a954 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:22:07 +0900 Subject: [PATCH 2/9] fix: match corroboration tokens at boundaries --- lineageweave/relation_verification.py | 11 +++++---- tests/test_relation_verification.py | 34 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 507336b72..e19b44e92 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -173,14 +173,17 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - host = urlparse(url).netloc.lower() if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None - tokens = [ + tokens = { token.lower() for token in _ORG_TOKEN.findall(organization_name) if token.lower() not in _ORG_TOKEN_STOPWORDS - ] + } if not tokens: return None - haystack = f"{host} {result.get('content') or ''}".lower() - if all(token in haystack for token in tokens): + haystack_tokens = { + token.lower() + for token in _ORG_TOKEN.findall(f"{host} {result.get('content') or ''}") + } + if tokens <= haystack_tokens: return url return None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f0a9b0c26..d11fa26a0 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -66,6 +66,7 @@ def _serve() -> tuple[HTTPServer, str]: def test_null_client_is_unavailable_not_silently_uncorroborated() -> None: + """A missing search channel is unavailable, not a negative finding.""" client = NullRelationVerificationClient() assert client.available is False with pytest.raises(RuntimeError): @@ -73,6 +74,7 @@ def test_null_client_is_unavailable_not_silently_uncorroborated() -> None: def test_searxng_client_reports_corroborated_with_evidence_url() -> None: + """A matching result returns corroboration and its evidence URL.""" server, base = _serve() try: client = SearxngRelationVerificationClient(base_url=base) @@ -87,6 +89,7 @@ def test_searxng_client_reports_corroborated_with_evidence_url() -> None: def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_is_empty() -> None: + """An empty result set remains explicitly uncorroborated.""" server, base = _serve() try: client = SearxngRelationVerificationClient(base_url=base) @@ -99,6 +102,7 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ def test_query_echo_on_a_search_host_is_not_corroboration() -> None: + """A search-result URL and echoed title cannot become evidence.""" assert ( corroborating_evidence_url( "Zzqxvthorp Fictitious Nonexistent Org", @@ -113,6 +117,7 @@ def test_query_echo_on_a_search_host_is_not_corroboration() -> None: def test_org_token_in_result_host_is_corroboration() -> None: + """A distinctive organization token in the host is evidence.""" assert ( corroborating_evidence_url( "Acme Corp", @@ -138,6 +143,7 @@ def test_partial_multi_token_name_is_not_corroboration() -> None: def test_all_distinctive_multi_token_name_parts_are_corroboration() -> None: + """All distinctive name tokens may be distributed across host and content.""" assert ( corroborating_evidence_url( "Aurora Grid Power", @@ -151,6 +157,32 @@ def test_all_distinctive_multi_token_name_parts_are_corroboration() -> None: ) +def test_title_only_full_name_is_not_corroboration() -> None: + """A title echo alone is not an organization footprint.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://news.example/item", + "title": "Aurora Grid Power", + "content": "", + }, + ) + is None + ) + + +def test_compound_host_token_is_not_two_name_tokens() -> None: + """A compound host word must not match separate organization tokens.""" + assert ( + corroborating_evidence_url( + "Green House", + {"url": "https://greenhouse.example/news", "title": "News", "content": ""}, + ) + is None + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( @@ -163,6 +195,7 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: def test_hangul_org_name_token_is_corroboration() -> None: + """A complete Hangul organization token in content is evidence.""" assert ( corroborating_evidence_url( "한빛그리드", @@ -177,5 +210,6 @@ def test_hangul_org_name_token_is_corroboration() -> None: def test_searxng_client_refuses_non_http_scheme() -> None: + """The client rejects non-HTTP URLs before making a request.""" with pytest.raises(ValueError, match="unsupported Searxng base URL scheme"): SearxngRelationVerificationClient(base_url="file:///etc/passwd") From 021f98ed06fc0bee853c2b1a20ca17f0feb16a87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:07:41 +0900 Subject: [PATCH 3/9] test: document stdlib log signature --- tests/test_relation_verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index d11fa26a0..12caff993 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -53,7 +53,7 @@ def do_GET(self) -> None: self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: + def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature return From 5cc0a676c79ce0dede5c41c9c2d7f40fb9efd536 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:37:57 +0900 Subject: [PATCH 4/9] fix: recognize bounded Korean organization particles --- lineageweave/relation_verification.py | 22 +++++- tests/test_relation_verification.py | 100 +++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index e19b44e92..a5d427b90 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -61,6 +61,11 @@ "and", } ) +_HANGUL_TOKEN = re.compile(r"[가-힣]+") +_KOREAN_PARTICLE_SUFFIX = re.compile( + r"(?:에게서|한테서|에서는|으로는|이라고|에서|에게|한테|께서|부터|까지|처럼|보다|만큼|" + r"으로|이랑|라고|이|가|은|는|을|를|의|에|께|와|과|도|만|로|랑|하고)+" +) STATUS_PENDING = "verify_pending" STATUS_CORROBORATED = "verify_corroborated" @@ -108,6 +113,7 @@ class NullRelationVerificationClient: available = False def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: # pragma: no cover + """Reject verification because this client has no search transport.""" raise RuntimeError( "NullRelationVerificationClient has no search channel; check .available first" ) @@ -134,6 +140,7 @@ class SearxngRelationVerificationClient: available = True def __init__(self, base_url: str, *, timeout: float = 15.0) -> None: + """Configure a validated Searxng base URL and request timeout.""" parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"}: raise ValueError(f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}") @@ -141,6 +148,7 @@ def __init__(self, base_url: str, *, timeout: float = 15.0) -> None: self._timeout = timeout def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + """Return the first corroborating result, or an explicit negative result.""" query = f"{organization_name} {relationship_label}" body = get_json( f"{self._base_url}/search?q={quote(query, safe='')}&format=json", @@ -184,6 +192,18 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - token.lower() for token in _ORG_TOKEN.findall(f"{host} {result.get('content') or ''}") } - if tokens <= haystack_tokens: + if all( + any(_organization_token_matches(token, candidate) for candidate in haystack_tokens) + for token in tokens + ): return url return None + + +def _organization_token_matches(expected: str, observed: str) -> bool: + """Match exact tokens, or a Hangul token followed only by Korean particles.""" + if expected == observed: + return True + if not _HANGUL_TOKEN.fullmatch(expected) or not observed.startswith(expected): + return False + return _KOREAN_PARTICLE_SUFFIX.fullmatch(observed[len(expected) :]) is not None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index 12caff993..c66451220 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -21,19 +21,34 @@ STATUS_CORROBORATED, STATUS_UNCORROBORATED, NullRelationVerificationClient, + RelationVerificationClient, SearxngRelationVerificationClient, corroborating_evidence_url, ) class _ResultsHandler(BaseHTTPRequestHandler): + """Serve deterministic search responses over the real HTTP boundary.""" + received_query: str = "" def do_GET(self) -> None: + """Return the response shape selected by the received search query.""" parsed = urlparse(self.path) query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] - if "Acme" in type(self).received_query: + if "Malformed" in type(self).received_query: + payload = {"query": type(self).received_query, "results": {"unexpected": True}} + elif "Mixed" in type(self).received_query: + payload = { + "query": type(self).received_query, + "results": [ + None, + {"url": "https://other.example/item", "content": "unrelated"}, + {"url": "https://mixed.example/item", "content": "Mixed Signal"}, + ], + } + elif "Acme" in type(self).received_query: payload = { "query": type(self).received_query, "results": [ @@ -54,10 +69,12 @@ def do_GET(self) -> None: self.wfile.write(body) def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + """Keep the test server quiet while preserving the stdlib signature.""" return def _serve() -> tuple[HTTPServer, str]: + """Start an ephemeral local HTTP server and return its base URL.""" server = HTTPServer(("127.0.0.1", 0), _ResultsHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -73,6 +90,12 @@ def test_null_client_is_unavailable_not_silently_uncorroborated() -> None: client.verify("Acme Corp", "Voice of Customer") +def test_protocol_stub_cannot_be_mistaken_for_a_verification() -> None: + """The protocol's runtime stub fails instead of fabricating a result.""" + with pytest.raises(NotImplementedError): + RelationVerificationClient.verify(object(), "Acme Corp", "Voice of Customer") + + def test_searxng_client_reports_corroborated_with_evidence_url() -> None: """A matching result returns corroboration and its evidence URL.""" server, base = _serve() @@ -101,6 +124,34 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ assert result.evidence_url is None +def test_searxng_client_rejects_malformed_results_shape() -> None: + """A non-list result collection cannot become corroborating evidence.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + "Malformed Organization", "Supplier" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_UNCORROBORATED + assert result.evidence_url is None + + +def test_searxng_client_skips_invalid_and_unrelated_results() -> None: + """The client scans past invalid and unrelated entries to real evidence.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + "Mixed Signal", "Supplier" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_CORROBORATED + assert result.evidence_url == "https://mixed.example/item" + + def test_query_echo_on_a_search_host_is_not_corroboration() -> None: """A search-result URL and echoed title cannot become evidence.""" assert ( @@ -116,6 +167,23 @@ def test_query_echo_on_a_search_host_is_not_corroboration() -> None: ) +@pytest.mark.parametrize("url", [None, "", "relative-path"]) +def test_missing_empty_or_relative_url_is_not_evidence(url: object) -> None: + """Evidence needs a non-empty absolute URL with a real host.""" + assert corroborating_evidence_url("Acme Corp", {"url": url}) is None + + +def test_name_with_only_legal_suffixes_has_no_distinctive_token() -> None: + """Legal suffix stopwords alone cannot identify an organization.""" + assert ( + corroborating_evidence_url( + "Corp Ltd", + {"url": "https://business.example/item", "content": "Corp Ltd"}, + ) + is None + ) + + def test_org_token_in_result_host_is_corroboration() -> None: """A distinctive organization token in the host is evidence.""" assert ( @@ -209,6 +277,36 @@ def test_hangul_org_name_token_is_corroboration() -> None: ) +def test_hangul_org_name_with_particle_is_corroboration() -> None: + """A Korean particle attached to the complete name keeps the token match.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드가 공급 일정을 발표했다.", + }, + ) + == "https://news.example/item" + ) + + +def test_longer_hangul_business_name_is_not_a_particle_match() -> None: + """An unrelated longer Korean name must not satisfy a shorter name token.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드솔루션이 공급 일정을 발표했다.", + }, + ) + is None + ) + + def test_searxng_client_refuses_non_http_scheme() -> None: """The client rejects non-HTTP URLs before making a request.""" with pytest.raises(ValueError, match="unsupported Searxng base URL scheme"): From 6a26683f544310c6855adaa7123a5295359970fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:43:10 +0900 Subject: [PATCH 5/9] fix: ignore URL userinfo during corroboration --- lineageweave/relation_verification.py | 6 +++++- tests/test_relation_verification.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index a5d427b90..132bf7b96 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -178,7 +178,11 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - url = result.get("url") if not isinstance(url, str) or not url.strip(): return None - host = urlparse(url).netloc.lower() + try: + parsed_url = urlparse(url) + host = (parsed_url.hostname or "").lower() + except ValueError: + return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None tokens = { diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index c66451220..3c18fbb68 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -251,6 +251,21 @@ def test_compound_host_token_is_not_two_name_tokens() -> None: ) +def test_userinfo_tokens_are_not_hostname_evidence() -> None: + """URL credentials cannot corroborate an unrelated actual hostname.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://aurora-grid-power.example@unrelated.example/news", + "title": "News", + "content": "", + }, + ) + is None + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( From db5dfdab1c1c78a22ac247c96ec9b2e9e3607ab1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:44:04 +0900 Subject: [PATCH 6/9] test: remove unused ruff suppression --- tests/test_relation_verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index 3c18fbb68..f0a412540 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -68,7 +68,7 @@ def do_GET(self) -> None: self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + def log_message(self, format: str, *args) -> None: """Keep the test server quiet while preserving the stdlib signature.""" return From c44f097dcade420d2d45d1ad3a872fde6f0578f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:48:21 +0900 Subject: [PATCH 7/9] test: cover malformed corroboration URLs --- tests/test_relation_verification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index 3c18fbb68..5cd785b26 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -173,6 +173,11 @@ def test_missing_empty_or_relative_url_is_not_evidence(url: object) -> None: assert corroborating_evidence_url("Acme Corp", {"url": url}) is None +def test_malformed_ipv6_url_is_not_evidence() -> None: + """Malformed bracketed hosts fail closed instead of crashing verification.""" + assert corroborating_evidence_url("Acme Corp", {"url": "https://[::1/x"}) is None + + def test_name_with_only_legal_suffixes_has_no_distinctive_token() -> None: """Legal suffix stopwords alone cannot identify an organization.""" assert ( From e6ef7cc53bcfef1e3dd61705b9ab243251860730 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:28:39 +0900 Subject: [PATCH 8/9] fix: reject non-http relation evidence links --- lineageweave/relation_verification.py | 2 ++ tests/test_relation_verification.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 132bf7b96..e07a6c5a5 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -183,6 +183,8 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - host = (parsed_url.hostname or "").lower() except ValueError: return None + if parsed_url.scheme not in {"http", "https"}: + return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None tokens = { diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index ca000422e..ba6597481 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -173,6 +173,12 @@ def test_missing_empty_or_relative_url_is_not_evidence(url: object) -> None: assert corroborating_evidence_url("Acme Corp", {"url": url}) is None +@pytest.mark.parametrize("url", ["file://acme.example/item", "javascript://acme.example/item"]) +def test_non_http_evidence_url_is_not_accepted(url: str) -> None: + """Evidence links must be browser-safe HTTP(S) resources.""" + assert corroborating_evidence_url("Acme Corp", {"url": url}) is None + + def test_malformed_ipv6_url_is_not_evidence() -> None: """Malformed bracketed hosts fail closed instead of crashing verification.""" assert corroborating_evidence_url("Acme Corp", {"url": "https://[::1/x"}) is None From 813ab63db4987f70de5b9bf434f7ee0a634ab5b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:49:01 +0900 Subject: [PATCH 9/9] fix: match contiguous Hangul organization names --- lineageweave/relation_verification.py | 17 +++++++++++------ tests/test_relation_verification.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index e07a6c5a5..3d1b8c366 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -187,11 +187,8 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None - tokens = { - token.lower() - for token in _ORG_TOKEN.findall(organization_name) - if token.lower() not in _ORG_TOKEN_STOPWORDS - } + organization_tokens = [token.lower() for token in _ORG_TOKEN.findall(organization_name)] + tokens = {token for token in organization_tokens if token not in _ORG_TOKEN_STOPWORDS} if not tokens: return None haystack_tokens = { @@ -201,11 +198,19 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - if all( any(_organization_token_matches(token, candidate) for candidate in haystack_tokens) for token in tokens - ): + ) or _concatenated_hangul_name_matches(organization_tokens, haystack_tokens): return url return None +def _concatenated_hangul_name_matches(expected_tokens: list[str], observed_tokens: set[str]) -> bool: + """Accept a spaced Hangul name when a page writes its parts contiguously.""" + if len(expected_tokens) < 2 or not all(_HANGUL_TOKEN.fullmatch(token) for token in expected_tokens): + return False + compact_name = "".join(expected_tokens) + return any(_organization_token_matches(compact_name, observed) for observed in observed_tokens) + + def _organization_token_matches(expected: str, observed: str) -> bool: """Match exact tokens, or a Hangul token followed only by Korean particles.""" if expected == observed: diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index ba6597481..83826838f 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -262,6 +262,21 @@ def test_compound_host_token_is_not_two_name_tokens() -> None: ) +def test_spaced_hangul_name_matches_contiguous_page_token() -> None: + """A page may concatenate the parts of a spaced Korean name.""" + assert ( + corroborating_evidence_url( + "한빛 그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드가 공급 일정을 발표했다.", + }, + ) + == "https://news.example/item" + ) + + def test_userinfo_tokens_are_not_hostname_evidence() -> None: """URL credentials cannot corroborate an unrelated actual hostname.""" assert (