From 577ebda05f8995cc3289c7305d0116f5b65014e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:25:44 +0900 Subject: [PATCH 01/51] test(keyman-backfill): require semantic identifiers --- ...ckfill_post_keyman_semantic_identifiers.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_backfill_post_keyman_semantic_identifiers.py diff --git a/tests/test_backfill_post_keyman_semantic_identifiers.py b/tests/test_backfill_post_keyman_semantic_identifiers.py new file mode 100644 index 000000000..02dd8832c --- /dev/null +++ b/tests/test_backfill_post_keyman_semantic_identifiers.py @@ -0,0 +1,84 @@ +"""Naming contract for the bounded post-Keyman operator command.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "backfill_post_keymen.py" + + +def test_post_keyman_backfill_uses_bounded_context_identifiers() -> None: + """Keep owned command, database, record, and result names semantic.""" + syntax_tree = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8")) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + forbidden_generic_identifiers = { + "_run", + "args", + "conn", + "exc", + "failures", + "limit", + "mention_count", + "name", + "names", + "normalized", + "parser", + "pool", + "processed", + "row", + "rows", + "selector", + "settings", + } + assert owned_identifiers.isdisjoint(forbidden_generic_identifiers) + assert { + "_run_post_keyman_backfill", + "command_arguments", + "database_connection", + "database_pool", + "post_records", + "processed_post_count", + "runtime_settings", + } <= owned_identifiers + + +def test_post_keyman_backfill_preserves_operator_contract() -> None: + """Keep released CLI flags and JSON result fields at the adapter boundary.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + for contract_literal in ( + '"--post-id"', + '"--all"', + '"--limit"', + '"--post-timeout"', + '"failed_posts"', + '"failure_types"', + '"mentions_persisted"', + '"processed_posts"', + '"requested_posts"', + ): + assert contract_literal in script_source + + syntax_tree = ast.parse(script_source) + asynchronous_entrypoint_calls = { + syntax_node.func.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Call) and isinstance(syntax_node.func, ast.Name) + } + assert "_run_post_keyman_backfill" in asynchronous_entrypoint_calls From 125e4128324873265e55e8a010b74cd45d89b026 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:25:46 +0900 Subject: [PATCH 02/51] refactor(keyman-backfill): use semantic operator identifiers --- scripts/backfill_post_keymen.py | 150 +++++++++++++++++++++----------- 1 file changed, 99 insertions(+), 51 deletions(-) diff --git a/scripts/backfill_post_keymen.py b/scripts/backfill_post_keymen.py index 59e2efd6c..9c15fe340 100644 --- a/scripts/backfill_post_keymen.py +++ b/scripts/backfill_post_keymen.py @@ -36,25 +36,39 @@ from lineageweave.post_content_normalization import normalize_post_body -def _first_env(*names: str) -> str: - return next((os.environ.get(name, "").strip() for name in names if os.environ.get(name, "").strip()), "") +def _first_env(*variable_names: str) -> str: + return next( + ( + os.environ.get(variable_name, "").strip() + for variable_name in variable_names + if os.environ.get(variable_name, "").strip() + ), + "", + ) def _orchestrator_config() -> tuple[str, str]: - base_url = _first_env("ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL") + base_url = _first_env( + "ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL" + ) api_key = _first_env("ORCHESTRATOR_API_KEY", "LLM_GATEWAY_API_KEY") if not base_url or not api_key: - raise RuntimeError("contextual-orchestrator gateway configuration is unavailable") + raise RuntimeError( + "contextual-orchestrator gateway configuration is unavailable" + ) return base_url, api_key async def _select_posts( - conn: asyncpg.Connection, *, limit: int, post_id: str | None + database_connection: asyncpg.Connection, + *, + post_limit: int, + post_id: str | None, ) -> list[asyncpg.Record]: """Select one explicit post or one bounded unprojected batch.""" if post_id: return list( - await conn.fetch( + await database_connection.fetch( """ select post_id, post_title, post_body, author_account_id, source_author_code, source_company_code, @@ -103,7 +117,7 @@ async def _select_posts( ) ) return list( - await conn.fetch( + await database_connection.fetch( """ select post_id, post_title, post_body, author_account_id, source_author_code, source_company_code, @@ -154,16 +168,18 @@ async def _select_posts( order by post.created_at, post.post_id limit $1::bigint """, - limit, + post_limit, ) ) -async def _run(args: argparse.Namespace) -> dict[str, object]: - if args.post_id and args.all: +async def _run_post_keyman_backfill( + command_arguments: argparse.Namespace, +) -> dict[str, object]: + if command_arguments.post_id and command_arguments.all: raise ValueError("--post-id and --all cannot be combined") base_url, api_key = _orchestrator_config() - settings = load_settings() + runtime_settings = load_settings() keyman_client = ContextualOrchestratorKeymanExtractionClient( base_url=base_url, api_key=api_key, timeout=180.0 ) @@ -171,68 +187,100 @@ async def _run(args: argparse.Namespace) -> dict[str, object]: resolution_client = _organization_name_resolution_client() verification_client = _relation_verification_client() hierarchy_client = _corporate_hierarchy_inference_client() - limit = 1 if args.post_id or not args.all else args.limit + post_limit = ( + 1 + if command_arguments.post_id or not command_arguments.all + else command_arguments.limit + ) - pool = await asyncpg.create_pool(settings.database_url, min_size=1, max_size=1) + database_pool = await asyncpg.create_pool( + runtime_settings.database_url, min_size=1, max_size=1 + ) try: - async with pool.acquire() as conn: - rows = await _select_posts(conn, limit=limit, post_id=args.post_id) - failures: Counter[str] = Counter() - processed = 0 - mention_count = 0 - for row in rows: - post_id = str(row["post_id"]) + async with database_pool.acquire() as database_connection: + post_records = await _select_posts( + database_connection, + post_limit=post_limit, + post_id=command_arguments.post_id, + ) + failure_counts: Counter[str] = Counter() + processed_post_count = 0 + persisted_mention_count = 0 + for post_record in post_records: + post_id = str(post_record["post_id"]) try: - async with asyncio.timeout(args.post_timeout): - with use_llm_metadata(build_post_llm_metadata(post_id, dict(row))): - normalized = normalize_post_body(row["post_body"] or "", vision_client) - context_hints = await _load_post_semantic_hints(conn, post_id) - mentions = await ingest_post_keymen( - conn, + async with asyncio.timeout(command_arguments.post_timeout): + with use_llm_metadata( + build_post_llm_metadata(post_id, dict(post_record)) + ): + normalized_post_content = normalize_post_body( + post_record["post_body"] or "", vision_client + ) + context_hints = await _load_post_semantic_hints( + database_connection, post_id + ) + persisted_mentions = await ingest_post_keymen( + database_connection, keyman_client, post_id, - row["post_title"] or "", - normalized.text, + post_record["post_title"] or "", + normalized_post_content.text, resolution_client=resolution_client, verification_client=verification_client, hierarchy_inference_client=hierarchy_client, context_hints=context_hints, ) - processed += 1 - mention_count += len(mentions) + processed_post_count += 1 + persisted_mention_count += len(persisted_mentions) except TimeoutError: - failures["TimeoutError"] += 1 - except (HttpClientError, OSError, RuntimeError, ValueError, asyncpg.PostgresError) as exc: - failures[type(exc).__name__] += 1 + failure_counts["TimeoutError"] += 1 + except ( + HttpClientError, + OSError, + RuntimeError, + ValueError, + asyncpg.PostgresError, + ) as backfill_error: + failure_counts[type(backfill_error).__name__] += 1 return { - "failed_posts": sum(failures.values()), - "failure_types": dict(sorted(failures.items())), - "mentions_persisted": mention_count, - "processed_posts": processed, - "requested_posts": len(rows), + "failed_posts": sum(failure_counts.values()), + "failure_types": dict(sorted(failure_counts.items())), + "mentions_persisted": persisted_mention_count, + "processed_posts": processed_post_count, + "requested_posts": len(post_records), } finally: - await pool.close() + await database_pool.close() def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - selector = parser.add_mutually_exclusive_group() - selector.add_argument("--post-id", help="Re-extract one eligible post") - selector.add_argument("--all", action="store_true", help="Process the explicit --limit batch") - parser.add_argument("--limit", type=int, default=1, help="Maximum posts for --all (default: 1)") - parser.add_argument( + argument_parser = argparse.ArgumentParser(description=__doc__) + post_selector = argument_parser.add_mutually_exclusive_group() + post_selector.add_argument("--post-id", help="Re-extract one eligible post") + post_selector.add_argument( + "--all", action="store_true", help="Process the explicit --limit batch" + ) + argument_parser.add_argument( + "--limit", type=int, default=1, help="Maximum posts for --all (default: 1)" + ) + argument_parser.add_argument( "--post-timeout", type=float, default=240.0, help="Maximum seconds per post including provider calls (default: 240)", ) - args = parser.parse_args() - if args.limit < 1: - parser.error("--limit must be positive") - if args.post_timeout <= 0: - parser.error("--post-timeout must be positive") - print(json.dumps(asyncio.run(_run(args)), ensure_ascii=False, sort_keys=True)) + command_arguments = argument_parser.parse_args() + if command_arguments.limit < 1: + argument_parser.error("--limit must be positive") + if command_arguments.post_timeout <= 0: + argument_parser.error("--post-timeout must be positive") + print( + json.dumps( + asyncio.run(_run_post_keyman_backfill(command_arguments)), + ensure_ascii=False, + sort_keys=True, + ) + ) if __name__ == "__main__": From 863b55e71c104aa5f3471343ede96402d6d6b7e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:25:49 +0900 Subject: [PATCH 03/51] docs(changelog): record semantic backfill identifiers --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2724eb8..b0884c082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- The bounded post-Keyman operator now uses semantic package-owned command, + database, record, and result identifiers while preserving every CLI flag, + JSON result field, SQL statement, and persistence boundary. + - ADRs 0011 and 0065 now include APA 7th References for the dated W3C PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are unchanged. From 5ac2d9f64117ef25df55e58b75ee6339938ebf40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:25:52 +0900 Subject: [PATCH 04/51] docs(gaps): record Keyman backfill naming evidence --- docs/product-technical-gap-baseline.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..1df6b224c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,15 @@ # Product & Technical Gap Baseline +> Exact-head naming overlay: 2026-09-07 KST. Protected `main` is +> `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded post-Keyman +> operator still used generic package-owned command, database, record, and +> result identifiers (`_run`, `args`, `conn`, `row`, `rows`, `settings`). +> Action: rename the complete private caller surface to the Keyman-backfill +> ubiquitous language, preserve CLI flags, JSON result fields, SQL, and +> persistence contracts, and keep the change Proposed until fresh exact-head +> checks and independent review complete. Status: implementation and AST +> regression GREEN locally; GitHub verification pending. + > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent From f0825c7cd5655bcee57e5c48b49bcc2ae1520b9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:31:03 +0900 Subject: [PATCH 05/51] test(thread-backfill): require semantic identifiers --- ...group_key_backfill_semantic_identifiers.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_thread_group_key_backfill_semantic_identifiers.py diff --git a/tests/test_thread_group_key_backfill_semantic_identifiers.py b/tests/test_thread_group_key_backfill_semantic_identifiers.py new file mode 100644 index 000000000..4b2801958 --- /dev/null +++ b/tests/test_thread_group_key_backfill_semantic_identifiers.py @@ -0,0 +1,75 @@ +"""Naming contract for the bounded thread-group-key backfill command.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "backfill_thread_group_keys.py" + + +def test_thread_group_key_backfill_uses_semantic_identifiers() -> None: + """Keep owned command, database, record, and count names semantic.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_run", + "args", + "cleared", + "conn", + "counts", + "parser", + "pool", + "project_evidence", + "row", + "rows", + "settings", + } + ) + assert { + "_run_thread_group_key_backfill", + "analysis_run_ids", + "command_arguments", + "database_connection", + "database_pool", + "runtime_settings", + "updated_post_records", + } <= owned_identifiers + + +def test_thread_group_key_backfill_preserves_operator_contract() -> None: + """Keep the CLI flag and aggregate JSON keys at the adapter boundary.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + for contract_literal in ( + '"--dry-run"', + '"cleared_placeholder_posts"', + '"project_secondary_evidence_posts"', + '"dry_run"', + ): + assert contract_literal in script_source + + syntax_tree = ast.parse(script_source) + called_functions = { + syntax_node.func.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Call) and isinstance(syntax_node.func, ast.Name) + } + assert "_run_thread_group_key_backfill" in called_functions From fa51c92525c60a5fec9ebe8af62b6d3ac9003d9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:31:05 +0900 Subject: [PATCH 06/51] refactor(thread-backfill): use semantic operator identifiers --- scripts/backfill_thread_group_keys.py | 87 ++++++++++++++++++--------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/scripts/backfill_thread_group_keys.py b/scripts/backfill_thread_group_keys.py index 8352c1ada..9413daa6e 100644 --- a/scripts/backfill_thread_group_keys.py +++ b/scripts/backfill_thread_group_keys.py @@ -61,7 +61,9 @@ from backend.app.config import load_settings -async def backfill_thread_group_keys(conn: asyncpg.Connection, *, dry_run: bool) -> dict[str, int]: +async def backfill_thread_group_keys( + database_connection: asyncpg.Connection, *, dry_run: bool +) -> dict[str, int]: """Clear placeholder grouping keys and route project codes to the secondary-key channel. @@ -72,8 +74,8 @@ async def backfill_thread_group_keys(conn: asyncpg.Connection, *, dry_run: bool) resolved live against `thread_group_key` on every read, not frozen in its snapshot. """ - async with conn.transaction(): - anchored_runs = await conn.fetch( + async with database_connection.transaction(): + anchored_analysis_runs = await database_connection.fetch( """ select scope.analysis_run_id, scope.scope_key from analysis_run_scope scope @@ -85,18 +87,21 @@ async def backfill_thread_group_keys(conn: asyncpg.Connection, *, dry_run: bool) ) """ ) - if anchored_runs: - run_ids = ", ".join(str(row["analysis_run_id"]) for row in anchored_runs) + if anchored_analysis_runs: + analysis_run_ids = ", ".join( + str(anchored_run["analysis_run_id"]) + for anchored_run in anchored_analysis_runs + ) raise RuntimeError( "refusing to rewrite thread_group_key: existing " - f"analysis_scope_thread_group run(s) [{run_ids}] resolve their " + f"analysis_scope_thread_group run(s) [{analysis_run_ids}] resolve their " "scope against values this backfill would change. Retire or " "re-scope those runs first." ) # Only rows carrying the placeholder signature -- a thread key equal # to the row's own record key groups nothing and can only be import # damage; a seeded or genuinely-mapped key never self-references. - rows = await conn.fetch( + updated_post_records = await database_connection.fetch( """ update source_post set source_thread_group_key = coalesce( @@ -112,55 +117,81 @@ async def backfill_thread_group_keys(conn: asyncpg.Connection, *, dry_run: bool) returning (nullif(btrim(source_project_code), '') is not null) as had_project_code """ ) - project_evidence = sum(1 for row in rows if row["had_project_code"]) - cleared = len(rows) + project_evidence_post_count = sum( + 1 + for updated_post_record in updated_post_records + if updated_post_record["had_project_code"] + ) + cleared_post_count = len(updated_post_records) if dry_run: - raise _RollbackDryRun(project_evidence, cleared) + raise _RollbackDryRun( + project_evidence_post_count, + cleared_post_count, + ) return { - "cleared_placeholder_posts": cleared, - "project_secondary_evidence_posts": project_evidence, + "cleared_placeholder_posts": cleared_post_count, + "project_secondary_evidence_posts": project_evidence_post_count, } class _RollbackDryRun(Exception): """Raised inside the transaction to force a rollback for --dry-run.""" - def __init__(self, project_evidence: int, cleared: int) -> None: + def __init__( + self, + project_evidence_post_count: int, + cleared_post_count: int, + ) -> None: """Retain the aggregate counts that the rolled-back operator run reports.""" super().__init__("dry run -- rolled back") - self.project_evidence = project_evidence - self.cleared = cleared + self.project_evidence_post_count = project_evidence_post_count + self.cleared_post_count = cleared_post_count -async def _run(args: argparse.Namespace) -> dict[str, object]: +async def _run_thread_group_key_backfill( + command_arguments: argparse.Namespace, +) -> dict[str, object]: """Execute one pooled backfill and convert dry-run rollback into counts.""" - settings = load_settings() - pool = await asyncpg.create_pool(settings.database_url, min_size=1, max_size=1) + runtime_settings = load_settings() + database_pool = await asyncpg.create_pool( + runtime_settings.database_url, min_size=1, max_size=1 + ) try: - async with pool.acquire() as conn: + async with database_pool.acquire() as database_connection: try: - counts = await backfill_thread_group_keys(conn, dry_run=args.dry_run) - return {**counts, "dry_run": False} + backfill_counts = await backfill_thread_group_keys( + database_connection, + dry_run=command_arguments.dry_run, + ) + return {**backfill_counts, "dry_run": False} except _RollbackDryRun as rolled_back: return { - "cleared_placeholder_posts": rolled_back.cleared, - "project_secondary_evidence_posts": rolled_back.project_evidence, + "cleared_placeholder_posts": rolled_back.cleared_post_count, + "project_secondary_evidence_posts": ( + rolled_back.project_evidence_post_count + ), "dry_run": True, } finally: - await pool.close() + await database_pool.close() def main() -> None: """Parse operator arguments and print aggregate, non-identifying evidence.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( "--dry-run", action="store_true", help="Report counts without writing (rolls back the transaction)", ) - args = parser.parse_args() - print(json.dumps(asyncio.run(_run(args)), ensure_ascii=False, sort_keys=True)) + command_arguments = argument_parser.parse_args() + print( + json.dumps( + asyncio.run(_run_thread_group_key_backfill(command_arguments)), + ensure_ascii=False, + sort_keys=True, + ) + ) if __name__ == "__main__": From e828b5b4a547a07e7f741b08eda4f8d177e6cb83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:31:07 +0900 Subject: [PATCH 07/51] test(thread-backfill): update semantic private callers --- tests/test_backfill_thread_group_keys.py | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/test_backfill_thread_group_keys.py b/tests/test_backfill_thread_group_keys.py index 8b83ad5d9..e7139363e 100644 --- a/tests/test_backfill_thread_group_keys.py +++ b/tests/test_backfill_thread_group_keys.py @@ -34,7 +34,9 @@ class _Connection: whose live scope match the rewrite would orphan. """ - def __init__(self, rows: list[bool], anchored_runs: list[str] | None = None) -> None: + def __init__( + self, rows: list[bool], anchored_runs: list[str] | None = None + ) -> None: self._rows = rows self._anchored_runs = anchored_runs or [] self.executed: list[str] = [] @@ -50,7 +52,9 @@ async def fetch(self, query: str, *args: object): {"analysis_run_id": run_id, "scope_key": f"key-{run_id}"} for run_id in self._anchored_runs ] - return [{"had_project_code": had_project_code} for had_project_code in self._rows] + return [ + {"had_project_code": had_project_code} for had_project_code in self._rows + ] def test_backfill_clears_placeholders_and_routes_project_codes_to_secondary() -> None: @@ -72,14 +76,19 @@ def test_backfill_clears_placeholders_and_routes_project_codes_to_secondary() -> # related posts that lack a project code, exactly the links the # reconstruction library exists to find. assert "thread_group_key = ''" in update - assert "secondary_grouping_key = coalesce(nullif(btrim(source_project_code), ''), '')" in update + assert ( + "secondary_grouping_key = coalesce(nullif(btrim(source_project_code), ''), '')" + in update + ) assert "source_thread_group_key = coalesce(" in update assert "source_thread_group_key, thread_group_key" in update assert "source_secondary_grouping_key = coalesce(" in update assert "source_secondary_grouping_key, secondary_grouping_key" in update -def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned() -> None: +def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned() -> ( + None +): # analysis_scope_thread_group runs resolve `thread_group_key = # scope_key` live on every read (ABAC visibility) -- their member # posts are snapshot-frozen but the scope match is not. Rewriting @@ -110,8 +119,8 @@ def test_dry_run_reports_counts_but_raises_to_force_a_rollback() -> None: try: asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=True)) except backfill._RollbackDryRun as rolled_back: - assert rolled_back.project_evidence == 1 - assert rolled_back.cleared == 2 + assert rolled_back.project_evidence_post_count == 1 + assert rolled_back.cleared_post_count == 2 else: raise AssertionError("expected _RollbackDryRun") @@ -139,12 +148,16 @@ def fake_load_settings(): monkeypatch.setattr(backfill, "load_settings", fake_load_settings) -def test_run_reports_dry_run_counts_without_the_internal_exception_leaking(monkeypatch) -> None: +def test_run_reports_dry_run_counts_without_the_internal_exception_leaking( + monkeypatch, +) -> None: import argparse conn = _Connection([True, True, False]) _patch_pool(monkeypatch, conn) - result = asyncio.run(backfill._run(argparse.Namespace(dry_run=True))) + result = asyncio.run( + backfill._run_thread_group_key_backfill(argparse.Namespace(dry_run=True)) + ) assert result == { "cleared_placeholder_posts": 3, "project_secondary_evidence_posts": 2, @@ -157,7 +170,9 @@ def test_run_reports_write_counts_when_not_a_dry_run(monkeypatch) -> None: conn = _Connection([True, False, False]) _patch_pool(monkeypatch, conn) - result = asyncio.run(backfill._run(argparse.Namespace(dry_run=False))) + result = asyncio.run( + backfill._run_thread_group_key_backfill(argparse.Namespace(dry_run=False)) + ) assert result == { "cleared_placeholder_posts": 3, "project_secondary_evidence_posts": 1, From c6c5bc28063da6e9f5199509dbdbb3438d532597 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:31:10 +0900 Subject: [PATCH 08/51] docs(changelog): record semantic thread backfill identifiers --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0884c082..7090652ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- The bounded thread-group-key backfill now uses semantic command, database, + record, and count identifiers while preserving `--dry-run`, aggregate JSON, + SQL, transaction rollback, and persistence behavior. + - The bounded post-Keyman operator now uses semantic package-owned command, database, record, and result identifiers while preserving every CLI flag, JSON result field, SQL statement, and persistence boundary. From b54cc6615a08c1b7678bbd447c453581b83f35a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:31:13 +0900 Subject: [PATCH 09/51] docs(gaps): record thread backfill naming evidence --- docs/product-technical-gap-baseline.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1df6b224c..fdc12872e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -10,6 +10,13 @@ > checks and independent review complete. Status: implementation and AST > regression GREEN locally; GitHub verification pending. +> Thread-group-key naming overlay: the separate bounded backfill command on the +> same exact protected head also used `_run`, `args`, `conn`, `pool`, `row`, and +> `rows`. Action: carry the same semantic naming rule through that complete +> private caller surface while preserving `--dry-run`, aggregate JSON fields, +> SQL, transaction rollback, and persistence behavior. Status: implementation, +> behavior tests, and AST regression GREEN locally; GitHub verification pending. + > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent From c098fe623c96d264c9b2a5f79bc96765ea73cc62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:19:54 +0900 Subject: [PATCH 10/51] test(channel-weights): require semantic estimator identifiers --- ..._weight_estimation_semantic_identifiers.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/test_channel_weight_estimation_semantic_identifiers.py diff --git a/tests/test_channel_weight_estimation_semantic_identifiers.py b/tests/test_channel_weight_estimation_semantic_identifiers.py new file mode 100644 index 000000000..108fdadb8 --- /dev/null +++ b/tests/test_channel_weight_estimation_semantic_identifiers.py @@ -0,0 +1,122 @@ +"""Naming and boundary contracts for deterministic channel-weight estimation.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/estimate_channel_weights.py") + + +def _function_identifiers(function_name: str) -> set[str]: + source_tree = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8")) + function_node = next( + node + for node in ast.walk(source_tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == function_name + ) + return { + identifier + for node in ast.walk(function_node) + for identifier in ( + [node.id] + if isinstance(node, ast.Name) + else [node.arg] + if isinstance(node, ast.arg) + else [] + ) + } + + +def test_owned_estimation_identifiers_are_semantic() -> None: + expected_identifiers = { + "source_snapshot_digest": { + "source_post_rows", + "source_post_row", + "digest_material", + }, + "sample_pair_scores": { + "lineage_records", + "candidate_window", + "channel_groups", + "lineage_record", + "ordered_records", + "candidate_record", + }, + "subsample_stride": { + "sample_pair_total", + "sample_pair_limit", + "sample_stride", + }, + "persist_estimate": { + "database_connection", + "channel_weight_estimate", + "installed_estimator_version", + "channel_code", + "weight_value", + }, + "_run_channel_weight_estimation": { + "command_arguments", + "runtime_settings", + "database_connection", + "source_post_rows", + "lineage_records", + "channel_weight_estimate", + }, + } + forbidden_identifiers = { + "args", + "candidate", + "channel", + "conn", + "estimate", + "groups", + "limit", + "material", + "record", + "records", + "row", + "rows", + "settings", + "stride", + "total", + "version", + "weight", + "window", + } + + for function_name, required_identifiers in expected_identifiers.items(): + function_identifiers = _function_identifiers(function_name) + assert required_identifiers <= function_identifiers + assert function_identifiers.isdisjoint(forbidden_identifiers) + + +def test_external_cli_json_and_persistence_contracts_are_unchanged() -> None: + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + assert '"--post-limit"' in script_source + assert '"--dry-run"' in script_source + for result_key in ( + "weights", + "channel_set_code", + "sample_pair_count", + "estimation_method_code", + "anchor_method_code", + "estimation_run_id", + "source_snapshot_sha256", + "knowledge_cutoff", + "persisted", + "activation", + ): + assert f'"{result_key}"' in script_source + assert "insert into lineage_channel_weight" in script_source + assert ( + "delete from lineage_channel_weight where channel_set_code = $1" + in script_source + ) + assert ( + "asyncio.run(_run_channel_weight_estimation(command_arguments))" + in script_source + ) From 29f2eb86f615a3792b99bb4a4efaa893ee6ddb62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:10 +0900 Subject: [PATCH 11/51] refactor(channel-weights): use semantic estimator identifiers --- scripts/estimate_channel_weights.py | 148 ++++++++++++++++------------ 1 file changed, 84 insertions(+), 64 deletions(-) diff --git a/scripts/estimate_channel_weights.py b/scripts/estimate_channel_weights.py index b8ab9534f..317cb2517 100644 --- a/scripts/estimate_channel_weights.py +++ b/scripts/estimate_channel_weights.py @@ -58,9 +58,9 @@ def estimator_version() -> str: """The installed fast-mlsirm version, for the persisted provenance.""" from importlib.metadata import PackageNotFoundError, version - for name in ("fast-mlsirm", "fast_mlsirm"): + for distribution_name in ("fast-mlsirm", "fast_mlsirm"): try: - return version(name) + return version(distribution_name) except PackageNotFoundError: continue import fast_mlsirm @@ -68,21 +68,22 @@ def estimator_version() -> str: return str(getattr(fast_mlsirm, "__version__", "unknown")) -def source_snapshot_digest(rows: list) -> str: +def source_snapshot_digest(source_post_rows: list) -> str: """Reproducible SHA-256 over the ordered sampled (post_id, created_at). Two runs that sampled the same posts in the same order produce the same digest, so the provenance row names exactly which corpus slice supported the estimate without storing any post content. """ - material = "\n".join( - f"{row['post_id']}\t{row['created_at'].isoformat()}" for row in rows + digest_material = "\n".join( + f"{source_post_row['post_id']}\t{source_post_row['created_at'].isoformat()}" + for source_post_row in source_post_rows ) - return hashlib.sha256(material.encode("utf-8")).hexdigest() + return hashlib.sha256(digest_material.encode("utf-8")).hexdigest() def sample_pair_scores( - records: list, *, window: int = DEFAULT_CANDIDATE_WINDOW + lineage_records: list, *, candidate_window: int = DEFAULT_CANDIDATE_WINDOW ) -> tuple[list[dict[str, float]], list[int], list[tuple[str, str]]]: """Score every in-window candidate pair, grouped as reconstruct groups. @@ -93,45 +94,54 @@ def sample_pair_scores( (candidate_label, record_label) so the queued llm judging pass can score the same candidate geometry without re-deriving it. """ - groups: dict[str, list] = {} - for record in records: - groups.setdefault(record.group_key, []).append(record) + channel_groups: dict[str, list] = {} + for lineage_record in lineage_records: + channel_groups.setdefault(lineage_record.group_key, []).append(lineage_record) pair_scores: list[dict[str, float]] = [] group_ids: list[int] = [] pair_labels: list[tuple[str, str]] = [] - for group_index, group_records in enumerate(groups.values()): - ordered = sorted(group_records, key=lambda r: r.occurred_at) - for index, record in enumerate(ordered): - for candidate in ordered[max(0, index - window) : index]: + for group_index, group_records in enumerate(channel_groups.values()): + ordered_records = sorted( + group_records, key=lambda grouped_record: grouped_record.occurred_at + ) + for record_index, lineage_record in enumerate(ordered_records): + for candidate_record in ordered_records[ + max(0, record_index - candidate_window) : record_index + ]: pair_scores.append( { - "temporal": temporal_score(candidate, record), - "secondary_key": secondary_key_match_score(candidate, record), - "text": text_similarity_score(candidate, record), + "temporal": temporal_score(candidate_record, lineage_record), + "secondary_key": secondary_key_match_score( + candidate_record, lineage_record + ), + "text": text_similarity_score(candidate_record, lineage_record), } ) group_ids.append(group_index) - pair_labels.append((candidate.label, record.label)) + pair_labels.append((candidate_record.label, lineage_record.label)) return pair_scores, group_ids, pair_labels -def subsample_stride(total: int, limit: int) -> list[int]: +def subsample_stride(sample_pair_total: int, sample_pair_limit: int) -> list[int]: """Deterministic, evenly-spread pair indices for the bounded llm pass. A stride subsample keeps every reconstruction group represented in proportion (pairs are ordered group-by-group) without any randomness that would make re-runs incomparable. """ - if total <= limit: - return list(range(total)) - stride = total / limit - return [min(int(index * stride), total - 1) for index in range(limit)] + if sample_pair_total <= sample_pair_limit: + return list(range(sample_pair_total)) + sample_stride = sample_pair_total / sample_pair_limit + return [ + min(int(sample_index * sample_stride), sample_pair_total - 1) + for sample_index in range(sample_pair_limit) + ] async def persist_estimate( - conn: asyncpg.Connection, - estimate: ChannelWeightEstimate, + database_connection: asyncpg.Connection, + channel_weight_estimate: ChannelWeightEstimate, *, channel_set_code: str, snapshot_sha256: str, @@ -142,14 +152,14 @@ async def persist_estimate( Returns the estimation run id stamped on every row of the set. """ estimation_run_id = str(uuid.uuid4()) - version = estimator_version() - async with conn.transaction(): - await conn.execute( + installed_estimator_version = estimator_version() + async with database_connection.transaction(): + await database_connection.execute( "delete from lineage_channel_weight where channel_set_code = $1", channel_set_code, ) - for channel, weight in estimate.weights.items(): - await conn.execute( + for channel_code, weight_value in channel_weight_estimate.weights.items(): + await database_connection.execute( """ insert into lineage_channel_weight (channel_set_code, channel_code, weight_value, @@ -160,45 +170,49 @@ async def persist_estimate( values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) """, channel_set_code, - channel, - weight, + channel_code, + weight_value, estimation_run_id, - estimate.estimation_method_code, - version, + channel_weight_estimate.estimation_method_code, + installed_estimator_version, UNANCHORED_METHOD_CODE, snapshot_sha256, - estimate.sample_pair_count, + channel_weight_estimate.sample_pair_count, knowledge_cutoff, ) return estimation_run_id -async def _run(args: argparse.Namespace) -> dict[str, object]: - settings = load_settings() +async def _run_channel_weight_estimation( + command_arguments: argparse.Namespace, +) -> dict[str, object]: + runtime_settings = load_settings() # Short-lived fetch connection; nothing stays open while fitting. - conn = await asyncpg.connect(settings.database_url) + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - rows = await conn.fetch( + source_post_rows = await database_connection.fetch( "select post_id, post_title, voc_type_code, created_at, " "corporate_entity_id, process_unit_id, thread_group_key, " "secondary_grouping_key " f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} " "order by created_at, post_id limit $1::bigint", - args.post_limit, + command_arguments.post_limit, ) finally: - await conn.close() - if not rows: + await database_connection.close() + if not source_post_rows: raise RuntimeError( "no eligible source posts exist; import a corpus before estimating" ) - snapshot_sha256 = source_snapshot_digest(rows) - knowledge_cutoff = max(row["created_at"] for row in rows) - records = records_from_source_posts(rows) - pair_scores, group_ids, _pair_labels = sample_pair_scores(records) + snapshot_sha256 = source_snapshot_digest(source_post_rows) + knowledge_cutoff = max( + source_post_row["created_at"] for source_post_row in source_post_rows + ) + lineage_records = records_from_source_posts(source_post_rows) + pair_scores, group_ids, _pair_labels = sample_pair_scores(lineage_records) - estimate = estimate_channel_weights(pair_scores, group_ids) - if estimate is None: + channel_weight_estimate = estimate_channel_weights(pair_scores, group_ids) + if channel_weight_estimate is None: raise RuntimeError( "no grounded estimate was produced (fast_mlsirm unavailable, " "sample too small, a channel degenerate, or the fit did not " @@ -206,28 +220,28 @@ async def _run(args: argparse.Namespace) -> dict[str, object]: "named condition" ) estimation_run_id = None - if not args.dry_run: - conn = await asyncpg.connect(settings.database_url) + if not command_arguments.dry_run: + database_connection = await asyncpg.connect(runtime_settings.database_url) try: estimation_run_id = await persist_estimate( - conn, - estimate, + database_connection, + channel_weight_estimate, channel_set_code=DETERMINISTIC_SET_CODE, snapshot_sha256=snapshot_sha256, knowledge_cutoff=knowledge_cutoff, ) finally: - await conn.close() + await database_connection.close() return { - "weights": estimate.weights, + "weights": channel_weight_estimate.weights, "channel_set_code": DETERMINISTIC_SET_CODE, - "sample_pair_count": estimate.sample_pair_count, - "estimation_method_code": estimate.estimation_method_code, + "sample_pair_count": channel_weight_estimate.sample_pair_count, + "estimation_method_code": channel_weight_estimate.estimation_method_code, "anchor_method_code": UNANCHORED_METHOD_CODE, "estimation_run_id": estimation_run_id, "source_snapshot_sha256": snapshot_sha256, "knowledge_cutoff": knowledge_cutoff.isoformat(), - "persisted": not args.dry_run, + "persisted": not command_arguments.dry_run, "activation": ( "blocked_until_anchor_authorized (ADR 0200 point 3): the " "product loader refuses every anchor method today, so these " @@ -238,22 +252,28 @@ async def _run(args: argparse.Namespace) -> dict[str, object]: def main() -> None: """Validate operator inputs and run the estimation.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( "--post-limit", type=int, default=5000, help="Maximum eligible posts to sample pairs from (default: 5000)", ) - parser.add_argument( + argument_parser.add_argument( "--dry-run", action="store_true", help="Estimate and report, but persist nothing", ) - args = parser.parse_args() - if args.post_limit < 1: - parser.error("--post-limit must be positive") - print(json.dumps(asyncio.run(_run(args)), ensure_ascii=False, sort_keys=True)) + command_arguments = argument_parser.parse_args() + if command_arguments.post_limit < 1: + argument_parser.error("--post-limit must be positive") + print( + json.dumps( + asyncio.run(_run_channel_weight_estimation(command_arguments)), + ensure_ascii=False, + sort_keys=True, + ) + ) if __name__ == "__main__": From 3face1f7d12a98a5cb82bc7c686c2a5efc95d6e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:19 +0900 Subject: [PATCH 12/51] test(channel-weights): use semantic helper arguments --- tests/test_estimate_channel_weights_script.py | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/tests/test_estimate_channel_weights_script.py b/tests/test_estimate_channel_weights_script.py index 3b086524d..19e46ab48 100644 --- a/tests/test_estimate_channel_weights_script.py +++ b/tests/test_estimate_channel_weights_script.py @@ -33,12 +33,14 @@ def _record(record_id: str, group: str, minute: int, secondary: str = "") -> Rec def test_sampling_stays_within_groups_and_window() -> None: - records = [ + lineage_records = [ _record("a1", "g-a", 0), _record("a2", "g-a", 1), _record("b1", "g-b", 2), ] - pair_scores, group_ids, pair_labels = script.sample_pair_scores(records, window=50) + pair_scores, group_ids, pair_labels = script.sample_pair_scores( + lineage_records, candidate_window=50 + ) # Only a1->a2 pairs up; b1 is alone in its group and never crosses. assert len(pair_scores) == 1 assert group_ids == [0] @@ -49,10 +51,12 @@ def test_sampling_stays_within_groups_and_window() -> None: def test_sampling_window_bounds_candidates_like_reconstruct() -> None: - records = [_record(f"r{index}", "g", index) for index in range(5)] - _, unbounded_ids, _ = script.sample_pair_scores(records, window=50) + lineage_records = [_record(f"r{index}", "g", index) for index in range(5)] + _, unbounded_ids, _ = script.sample_pair_scores( + lineage_records, candidate_window=50 + ) assert len(unbounded_ids) == 4 + 3 + 2 + 1 - pair_scores, _, _ = script.sample_pair_scores(records, window=2) + pair_scores, _, _ = script.sample_pair_scores(lineage_records, candidate_window=2) # Each record sees at most its two immediate predecessors. assert len(pair_scores) == 1 + 2 + 2 + 2 @@ -71,13 +75,13 @@ def test_llm_subsample_stride_is_deterministic_and_spread() -> None: def test_snapshot_digest_is_reproducible_and_order_sensitive() -> None: - rows = [ + source_post_rows = [ {"post_id": "a", "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}, {"post_id": "b", "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc)}, ] - first = script.source_snapshot_digest(rows) - assert first == script.source_snapshot_digest(list(rows)) - assert first != script.source_snapshot_digest(list(reversed(rows))) + first = script.source_snapshot_digest(source_post_rows) + assert first == script.source_snapshot_digest(list(source_post_rows)) + assert first != script.source_snapshot_digest(list(reversed(source_post_rows))) assert len(first) == 64 @@ -94,39 +98,44 @@ async def execute(self, query: str, *args: object) -> str: return "OK" -def test_persist_estimate_stamps_full_provenance_on_one_scoped_set() -> None: - conn = _Connection() - estimate = ChannelWeightEstimate( +def test_persist_estimate_stamps_full_provenance_on_one_scoped_set( + monkeypatch, +) -> None: + monkeypatch.setattr(script, "estimator_version", lambda: "0.9.1") + database_connection = _Connection() + channel_weight_estimate = ChannelWeightEstimate( weights={"temporal": 0.25, "text": 0.75}, sample_pair_count=600, estimation_method_code="mls2plm_expected_information", ) - cutoff = datetime(2026, 1, 2, tzinfo=timezone.utc) - run_id = asyncio.run( + knowledge_cutoff = datetime(2026, 1, 2, tzinfo=timezone.utc) + estimation_run_id = asyncio.run( script.persist_estimate( - conn, - estimate, + database_connection, + channel_weight_estimate, channel_set_code=script.DETERMINISTIC_SET_CODE, snapshot_sha256="a" * 64, - knowledge_cutoff=cutoff, + knowledge_cutoff=knowledge_cutoff, ) ) - delete_query, delete_args = conn.executed[0] + delete_query, delete_args = database_connection.executed[0] # Scoped delete: persisting the deterministic set must never wipe # another set -- each active-channel combination owns its own rows. - assert "delete from lineage_channel_weight where channel_set_code = $1" in delete_query + assert ( + "delete from lineage_channel_weight where channel_set_code = $1" in delete_query + ) assert delete_args == (script.DETERMINISTIC_SET_CODE,) - inserted = {call[1][1]: call[1] for call in conn.executed[1:]} + inserted = {call[1][1]: call[1] for call in database_connection.executed[1:]} assert set(inserted) == {"temporal", "text"} - for row in inserted.values(): - assert row[0] == script.DETERMINISTIC_SET_CODE - assert row[3] == run_id - assert row[4] == "mls2plm_expected_information" - assert isinstance(row[5], str) and row[5].strip() - assert row[6] == script.UNANCHORED_METHOD_CODE - assert row[7] == "a" * 64 - assert row[8] == 600 - assert row[9] == cutoff + for persisted_row in inserted.values(): + assert persisted_row[0] == script.DETERMINISTIC_SET_CODE + assert persisted_row[3] == estimation_run_id + assert persisted_row[4] == "mls2plm_expected_information" + assert isinstance(persisted_row[5], str) and persisted_row[5].strip() + assert persisted_row[6] == script.UNANCHORED_METHOD_CODE + assert persisted_row[7] == "a" * 64 + assert persisted_row[8] == 600 + assert persisted_row[9] == knowledge_cutoff assert inserted["text"][2] == 0.75 From cb4a0a515dc42fad77d1d009128586373ed11ed4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:28 +0900 Subject: [PATCH 13/51] docs(channel-weights): record semantic estimator vocabulary --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7090652ed..4033ebc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,12 @@ All notable changes to this project are documented here. Format follows ### Changed +- The deterministic channel-weight estimator now uses semantic source-post, + candidate-window, database, estimate, and command identifiers while + preserving pair sampling, fitting, CLI, JSON, SQL, and persisted provenance + contracts. The fast-mlsirm `v0.9.1` consumer cutover remains isolated in + #967. + - The bounded thread-group-key backfill now uses semantic command, database, record, and count identifiers while preserving `--dry-run`, aggregate JSON, SQL, transaction rollback, and persistence behavior. From 965668b65f5d91a0e53196de5191394d114592e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:36 +0900 Subject: [PATCH 14/51] docs(gaps): add channel-weight naming evidence --- docs/product-technical-gap-baseline.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fdc12872e..b3404a730 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,17 @@ # Product & Technical Gap Baseline +> Deterministic channel-weight estimator naming overlay: 2026-09-07 KST. +> Protected `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. +> The ADR 0200 operator used generic package-owned sampling, database, +> estimate, and command identifiers (`rows`, `record`, `window`, `conn`, +> `estimate`, `_run`, `args`). Action: translate those private identifiers to +> source-post, candidate-window, channel-weight, and command language while +> preserving pair geometry, weight fitting, CLI flags, JSON fields, SQL, and +> persisted provenance. The immutable fast-mlsirm `v0.9.1` consumer cutover is +> owned separately by #967; this naming slice adds no source fallback or +> dependency change. Status: implementation, behavioral tests, and AST +> regression GREEN locally; GitHub exact-head verification pending. + > Exact-head naming overlay: 2026-09-07 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded post-Keyman > operator still used generic package-owned command, database, record, and From cdeaf039551899c77e68e28b6ef79e74ffee4f80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:26:33 +0900 Subject: [PATCH 15/51] test(channel-weights): require semantic queued-estimator identifiers --- ..._weight_estimation_semantic_identifiers.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_llm_channel_weight_estimation_semantic_identifiers.py diff --git a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py new file mode 100644 index 000000000..9ceb44358 --- /dev/null +++ b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py @@ -0,0 +1,111 @@ +"""Naming and boundary contracts for queued LLM channel-weight estimation.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/estimate_llm_channel_weights.py") + + +def test_owned_llm_estimation_identifiers_are_semantic() -> None: + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_collect", + "_submit", + "args", + "chosen", + "collect", + "conn", + "estimate", + "exc", + "item", + "name", + "ordinal", + "pairs", + "parser", + "polled", + "result", + "results", + "retrieved", + "row", + "rows", + "run", + "score", + "scores", + "settings", + "submit", + "submitted", + "subcommands", + "unjudged", + "updates", + } + ) + assert { + "_collect_batch_estimation", + "_submit_batch_estimation", + "batch_result_record", + "batch_result_records", + "batch_results_payload", + "batch_status_payload", + "channel_weight_estimate", + "chosen_pair_ordinals", + "command_arguments", + "database_connection", + "estimation_run_record", + "judgment_updates", + "orchestrator_api_key", + "orchestrator_base_url", + "pair_judgment_records", + "pair_ordinal", + "runtime_settings", + "source_post_rows", + } <= owned_identifiers + + +def test_llm_provider_cli_json_and_persistence_contracts_are_unchanged() -> None: + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"custom_id"', + '"mode"', + '"messages"', + '"role"', + '"content"', + '"job_id"', + '"results"', + '"status"', + '"--post-limit"', + '"--pair-limit"', + '"--run-id"', + '"estimation_run_id"', + '"batch_job_id"', + '"sampled_pair_count"', + '"next_action"', + "insert into lineage_weight_estimation_run", + "insert into lineage_pair_judgment", + "update lineage_pair_judgment", + "update lineage_weight_estimation_run", + ): + assert contract_literal in script_source + assert "asyncio.run(_submit_batch_estimation(command_arguments))" in script_source + assert "asyncio.run(_collect_batch_estimation(command_arguments))" in script_source From 7958e000d5752728ad140e2d579cc68f303810e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:26:41 +0900 Subject: [PATCH 16/51] refactor(channel-weights): use semantic queued-estimator identifiers --- scripts/estimate_llm_channel_weights.py | 314 +++++++++++++----------- 1 file changed, 174 insertions(+), 140 deletions(-) diff --git a/scripts/estimate_llm_channel_weights.py b/scripts/estimate_llm_channel_weights.py index 1613ceb90..1beef15f1 100644 --- a/scripts/estimate_llm_channel_weights.py +++ b/scripts/estimate_llm_channel_weights.py @@ -55,32 +55,38 @@ def _orchestrator_config() -> tuple[str, str]: """Base URL and bearer key for the batch routing API, from the environment.""" - base_url = next( + orchestrator_base_url = next( ( - os.environ[name].strip() - for name in ("ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL") - if os.environ.get(name, "").strip() + os.environ[environment_variable_name].strip() + for environment_variable_name in ( + "ORCHESTRATOR_BASE_URL", + "LLM_GATEWAY_API_URL", + ) + if os.environ.get(environment_variable_name, "").strip() ), "", ) - api_key = next( + orchestrator_api_key = next( ( - os.environ[name].strip() - for name in ("ORCHESTRATOR_API_KEY", "CONTEXTUAL_ORCHESTRATOR_TOKEN") - if os.environ.get(name, "").strip() + os.environ[environment_variable_name].strip() + for environment_variable_name in ( + "ORCHESTRATOR_API_KEY", + "CONTEXTUAL_ORCHESTRATOR_TOKEN", + ) + if os.environ.get(environment_variable_name, "").strip() ), "", ) - if not base_url or not api_key: + if not orchestrator_base_url or not orchestrator_api_key: raise RuntimeError( "set ORCHESTRATOR_BASE_URL and ORCHESTRATOR_API_KEY (or " "CONTEXTUAL_ORCHESTRATOR_TOKEN) to reach the batch routing API" ) - return base_url.rstrip("/"), api_key + return orchestrator_base_url.rstrip("/"), orchestrator_api_key def batch_requests_for_pairs( - chosen: list[int], pair_labels: list[tuple[str, str]] + chosen_pair_ordinals: list[int], candidate_pair_labels: list[tuple[str, str]] ) -> list[dict[str, object]]: """One batch request per chosen pair, keyed by its ordinal. @@ -91,60 +97,70 @@ def batch_requests_for_pairs( """ return [ { - "custom_id": f"pair-{ordinal}", + "custom_id": f"pair-{pair_ordinal}", "mode": "auto", "messages": [ { "role": "user", - "content": judge_prompt(*pair_labels[ordinal]), + "content": judge_prompt(*candidate_pair_labels[pair_ordinal]), } ], } - for ordinal in chosen + for pair_ordinal in chosen_pair_ordinals ] -async def _submit(args: argparse.Namespace) -> dict[str, object]: +async def _submit_batch_estimation( + command_arguments: argparse.Namespace, +) -> dict[str, object]: """Sample, submit one batch job, persist the run ledger. Never waits.""" - base_url, api_key = _orchestrator_config() - settings = load_settings() - conn = await asyncpg.connect(settings.database_url) + orchestrator_base_url, orchestrator_api_key = _orchestrator_config() + runtime_settings = load_settings() + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - rows = await conn.fetch( + source_post_rows = await database_connection.fetch( "select post_id, post_title, voc_type_code, created_at, " "corporate_entity_id, process_unit_id, thread_group_key, " "secondary_grouping_key " f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} " "order by created_at, post_id limit $1::bigint", - args.post_limit, + command_arguments.post_limit, ) finally: - await conn.close() - if not rows: + await database_connection.close() + if not source_post_rows: raise RuntimeError( "no eligible source posts exist; import a corpus before estimating" ) - snapshot_sha256 = source_snapshot_digest(rows) - knowledge_cutoff = max(row["created_at"] for row in rows) - pair_scores, group_ids, pair_labels = sample_pair_scores( - records_from_source_posts(rows) + source_snapshot_sha256 = source_snapshot_digest(source_post_rows) + knowledge_cutoff = max( + source_post_row["created_at"] for source_post_row in source_post_rows + ) + candidate_pair_scores, reconstruction_group_ids, candidate_pair_labels = ( + sample_pair_scores(records_from_source_posts(source_post_rows)) ) - chosen = subsample_stride(len(pair_scores), args.pair_limit) - if not chosen: + chosen_pair_ordinals = subsample_stride( + len(candidate_pair_scores), command_arguments.pair_limit + ) + if not chosen_pair_ordinals: raise RuntimeError("the corpus produced no candidate pairs to judge") - submitted = post_json( - f"{base_url}/api/v1/batch_routing_jobs", - {"requests": batch_requests_for_pairs(chosen, pair_labels)}, - headers={"authorization": f"Bearer {api_key}"}, + batch_submission = post_json( + f"{orchestrator_base_url}/api/v1/batch_routing_jobs", + { + "requests": batch_requests_for_pairs( + chosen_pair_ordinals, candidate_pair_labels + ) + }, + headers={"authorization": f"Bearer {orchestrator_api_key}"}, timeout=_BATCH_TIMEOUT_SECONDS, ) - batch_job_id = str(submitted["job_id"]) + batch_job_id = str(batch_submission["job_id"]) - conn = await asyncpg.connect(settings.database_url) + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - async with conn.transaction(): - estimation_run_id = await conn.fetchval( + async with database_connection.transaction(): + estimation_run_id = await database_connection.fetchval( """ insert into lineage_weight_estimation_run (estimation_run_id, channel_set_code, run_status_code, @@ -155,14 +171,14 @@ async def _submit(args: argparse.Namespace) -> dict[str, object]: """, WITH_LLM_SET_CODE, batch_job_id, - snapshot_sha256, + source_snapshot_sha256, knowledge_cutoff, - len(chosen), + len(chosen_pair_ordinals), ) - for ordinal in chosen: - scores = pair_scores[ordinal] - candidate_label, record_label = pair_labels[ordinal] - await conn.execute( + for pair_ordinal in chosen_pair_ordinals: + channel_scores = candidate_pair_scores[pair_ordinal] + candidate_label, record_label = candidate_pair_labels[pair_ordinal] + await database_connection.execute( """ insert into lineage_pair_judgment (estimation_run_id, pair_ordinal, group_ordinal, @@ -171,39 +187,42 @@ async def _submit(args: argparse.Namespace) -> dict[str, object]: values ($1, $2, $3, $4, $5, $6, $7, $8) """, estimation_run_id, - ordinal, - group_ids[ordinal], + pair_ordinal, + reconstruction_group_ids[pair_ordinal], candidate_label, record_label, - scores["temporal"], - scores["secondary_key"], - scores["text"], + channel_scores["temporal"], + channel_scores["secondary_key"], + channel_scores["text"], ) - except Exception as exc: + except Exception as submission_error: raise RuntimeError( f"batch job {batch_job_id} was submitted but the run ledger " "could not be persisted; re-run submit (the orphaned job only " "costs its provider spend, no state references it)" - ) from exc + ) from submission_error finally: - await conn.close() + await database_connection.close() return { "estimation_run_id": str(estimation_run_id), "batch_job_id": batch_job_id, - "sampled_pair_count": len(chosen), + "sampled_pair_count": len(chosen_pair_ordinals), "next_action": "run collect once the batch job completes", } -def _is_complete(polled: dict[str, object]) -> bool: +def _is_complete(batch_status_payload: dict[str, object]) -> bool: """True when the batch backend reports a terminal successful state.""" - if polled.get("is_complete") is True: + if batch_status_payload.get("is_complete") is True: return True - return str(polled.get("status", "")).lower() in {"completed", "succeeded"} + return str(batch_status_payload.get("status", "")).lower() in { + "completed", + "succeeded", + } def judgment_updates_from_results( - results: list[dict[str, object]], + batch_result_records: list[dict[str, object]], ) -> list[tuple[int, float]]: """Map batch results onto (pair_ordinal, llm_score) updates. @@ -212,36 +231,38 @@ def judgment_updates_from_results( errored request must stay unjudged rather than become a confident 0.0 ("definitely unrelated") verdict the judge never gave. """ - updates: list[tuple[int, float]] = [] - for item in results: - custom_id = str(item.get("custom_id", "")) + judgment_updates: list[tuple[int, float]] = [] + for batch_result_record in batch_result_records: + custom_id = str(batch_result_record.get("custom_id", "")) if not custom_id.startswith("pair-"): continue try: - ordinal = int(custom_id.removeprefix("pair-")) + pair_ordinal = int(custom_id.removeprefix("pair-")) except ValueError: continue - score = parse_confidence_or_none(str(item.get("answer", ""))) - if score is None: + llm_score = parse_confidence_or_none(str(batch_result_record.get("answer", ""))) + if llm_score is None: continue - updates.append((ordinal, score)) - return updates + judgment_updates.append((pair_ordinal, llm_score)) + return judgment_updates -async def _collect(args: argparse.Namespace) -> dict[str, object]: +async def _collect_batch_estimation( + command_arguments: argparse.Namespace, +) -> dict[str, object]: """Collect one completed batch into the ledger; fit when the run is whole. No database connection is held across the HTTP calls or the model fit (an idle-reaped connection killed an earlier estimation run): each phase opens its own short-lived connection. """ - base_url, api_key = _orchestrator_config() - settings = load_settings() + orchestrator_base_url, orchestrator_api_key = _orchestrator_config() + runtime_settings = load_settings() - conn = await asyncpg.connect(settings.database_url) + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - if args.run_id: - run = await conn.fetchrow( + if command_arguments.run_id: + estimation_run_record = await database_connection.fetchrow( """ select estimation_run_id, batch_job_id, run_status_code, source_snapshot_sha256, knowledge_cutoff, sampled_pair_count @@ -249,10 +270,10 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: where estimation_run_id = $1::uuid and run_status_code in ('run_submitted', 'run_collecting') """, - args.run_id, + command_arguments.run_id, ) else: - run = await conn.fetchrow( + estimation_run_record = await database_connection.fetchrow( """ select estimation_run_id, batch_job_id, run_status_code, source_snapshot_sha256, knowledge_cutoff, sampled_pair_count @@ -263,52 +284,56 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: """ ) finally: - await conn.close() - if run is None: + await database_connection.close() + if estimation_run_record is None: raise RuntimeError( "no submitted run awaits collection; run submit first " "(or pass --run-id for an older run)" ) - polled = get_json( - f"{base_url}/api/v1/batch_routing_jobs/{run['batch_job_id']}", - headers={"authorization": f"Bearer {api_key}"}, + batch_status_payload = get_json( + f"{orchestrator_base_url}/api/v1/batch_routing_jobs/" + f"{estimation_run_record['batch_job_id']}", + headers={"authorization": f"Bearer {orchestrator_api_key}"}, timeout=_BATCH_TIMEOUT_SECONDS, service_peer_name="contextual-orchestrator", ) - if not _is_complete(polled): + if not _is_complete(batch_status_payload): return { - "estimation_run_id": str(run["estimation_run_id"]), - "batch_job_id": run["batch_job_id"], - "batch_status": polled.get("status"), + "estimation_run_id": str(estimation_run_record["estimation_run_id"]), + "batch_job_id": estimation_run_record["batch_job_id"], + "batch_status": batch_status_payload.get("status"), "next_action": "batch not complete yet; run collect again later", } - retrieved = post_json( - f"{base_url}/api/v1/batch_routing_jobs/{run['batch_job_id']}/results", + batch_results_payload = post_json( + f"{orchestrator_base_url}/api/v1/batch_routing_jobs/" + f"{estimation_run_record['batch_job_id']}/results", {}, - headers={"authorization": f"Bearer {api_key}"}, + headers={"authorization": f"Bearer {orchestrator_api_key}"}, timeout=_BATCH_TIMEOUT_SECONDS, ) - updates = judgment_updates_from_results(retrieved.get("results", [])) + judgment_updates = judgment_updates_from_results( + batch_results_payload.get("results", []) + ) judged_at = datetime.now(timezone.utc) - conn = await asyncpg.connect(settings.database_url) + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - async with conn.transaction(): - for ordinal, score in updates: - await conn.execute( + async with database_connection.transaction(): + for pair_ordinal, llm_score in judgment_updates: + await database_connection.execute( """ update lineage_pair_judgment set llm_score = $3, judged_at = $4 where estimation_run_id = $1 and pair_ordinal = $2 """, - run["estimation_run_id"], - ordinal, - score, + estimation_run_record["estimation_run_id"], + pair_ordinal, + llm_score, judged_at, ) - await conn.execute( + await database_connection.execute( """ update lineage_weight_estimation_run set run_status_code = 'run_collecting', @@ -318,9 +343,9 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: ) where estimation_run_id = $1 """, - run["estimation_run_id"], + estimation_run_record["estimation_run_id"], ) - pairs = await conn.fetch( + pair_judgment_records = await database_connection.fetch( """ select group_ordinal, temporal_score, secondary_key_score, text_score, llm_score @@ -328,46 +353,53 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: where estimation_run_id = $1 order by pair_ordinal """, - run["estimation_run_id"], + estimation_run_record["estimation_run_id"], ) finally: - await conn.close() + await database_connection.close() - unjudged = sum(1 for row in pairs if row["llm_score"] is None) - if unjudged: + unjudged_pair_count = sum( + 1 + for pair_judgment_record in pair_judgment_records + if pair_judgment_record["llm_score"] is None + ) + if unjudged_pair_count: return { - "estimation_run_id": str(run["estimation_run_id"]), - "judged_pair_count": len(pairs) - unjudged, - "sampled_pair_count": len(pairs), + "estimation_run_id": str(estimation_run_record["estimation_run_id"]), + "judged_pair_count": len(pair_judgment_records) - unjudged_pair_count, + "sampled_pair_count": len(pair_judgment_records), "next_action": ( - f"{unjudged} pairs have no parseable judgment yet; run " + f"{unjudged_pair_count} pairs have no parseable judgment yet; run " "collect again once the batch delivers them, or re-submit " "if the provider errored them permanently" ), } # The fit can take minutes; no connection is open while it runs. - estimate = estimate_channel_weights( + channel_weight_estimate = estimate_channel_weights( [ { - "temporal": row["temporal_score"], - "secondary_key": row["secondary_key_score"], - "text": row["text_score"], - "llm": row["llm_score"], + "temporal": pair_judgment_record["temporal_score"], + "secondary_key": pair_judgment_record["secondary_key_score"], + "text": pair_judgment_record["text_score"], + "llm": pair_judgment_record["llm_score"], } - for row in pairs + for pair_judgment_record in pair_judgment_records + ], + [ + int(pair_judgment_record["group_ordinal"]) + for pair_judgment_record in pair_judgment_records ], - [int(row["group_ordinal"]) for row in pairs], ) - conn = await asyncpg.connect(settings.database_url) + database_connection = await asyncpg.connect(runtime_settings.database_url) try: - if estimate is None: - await conn.execute( + if channel_weight_estimate is None: + await database_connection.execute( "update lineage_weight_estimation_run " "set run_status_code = 'run_failed', completed_at = now() " "where estimation_run_id = $1", - run["estimation_run_id"], + estimation_run_record["estimation_run_id"], ) raise RuntimeError( "no grounded estimate was produced over the judged pairs " @@ -376,26 +408,26 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: "marked run_failed; nothing was written to the weight table" ) await persist_estimate( - conn, - estimate, + database_connection, + channel_weight_estimate, channel_set_code=WITH_LLM_SET_CODE, - snapshot_sha256=run["source_snapshot_sha256"], - knowledge_cutoff=run["knowledge_cutoff"], + snapshot_sha256=estimation_run_record["source_snapshot_sha256"], + knowledge_cutoff=estimation_run_record["knowledge_cutoff"], ) - await conn.execute( + await database_connection.execute( "update lineage_weight_estimation_run " "set run_status_code = 'run_fitted', completed_at = now() " "where estimation_run_id = $1", - run["estimation_run_id"], + estimation_run_record["estimation_run_id"], ) finally: - await conn.close() + await database_connection.close() return { - "estimation_run_id": str(run["estimation_run_id"]), - "weights": estimate.weights, + "estimation_run_id": str(estimation_run_record["estimation_run_id"]), + "weights": channel_weight_estimate.weights, "channel_set_code": WITH_LLM_SET_CODE, - "sample_pair_count": estimate.sample_pair_count, - "estimation_method_code": estimate.estimation_method_code, + "sample_pair_count": channel_weight_estimate.sample_pair_count, + "estimation_method_code": channel_weight_estimate.estimation_method_code, "activation": ( "blocked_until_anchor_authorized (ADR 0200 point 3): the " "product loader refuses every anchor method today" @@ -405,29 +437,31 @@ async def _collect(args: argparse.Namespace) -> dict[str, object]: def main() -> None: """Validate operator inputs and run the chosen phase.""" - parser = argparse.ArgumentParser(description=__doc__) - subcommands = parser.add_subparsers(dest="phase", required=True) - submit = subcommands.add_parser("submit", help="sample pairs and submit one batch job") - submit.add_argument("--post-limit", type=int, default=5000) - submit.add_argument("--pair-limit", type=int, default=400) - collect = subcommands.add_parser( + argument_parser = argparse.ArgumentParser(description=__doc__) + phase_subparsers = argument_parser.add_subparsers(dest="phase", required=True) + submit_parser = phase_subparsers.add_parser( + "submit", help="sample pairs and submit one batch job" + ) + submit_parser.add_argument("--post-limit", type=int, default=5000) + submit_parser.add_argument("--pair-limit", type=int, default=400) + collect_parser = phase_subparsers.add_parser( "collect", help="collect results; fit when the run is whole" ) - collect.add_argument( + collect_parser.add_argument( "--run-id", default="", help="collect a specific estimation run (default: the newest awaiting one)", ) - args = parser.parse_args() - if args.phase == "submit": - if args.post_limit < 1: - parser.error("--post-limit must be positive") - if args.pair_limit < 1: - parser.error("--pair-limit must be positive") - result = asyncio.run(_submit(args)) + command_arguments = argument_parser.parse_args() + if command_arguments.phase == "submit": + if command_arguments.post_limit < 1: + argument_parser.error("--post-limit must be positive") + if command_arguments.pair_limit < 1: + argument_parser.error("--pair-limit must be positive") + command_result = asyncio.run(_submit_batch_estimation(command_arguments)) else: - result = asyncio.run(_collect(args)) - print(json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)) + command_result = asyncio.run(_collect_batch_estimation(command_arguments)) + print(json.dumps(command_result, ensure_ascii=False, sort_keys=True, default=str)) if __name__ == "__main__": From 299b5f0572576e054bb9fcf832cfe3aba8af2ac2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:26:48 +0900 Subject: [PATCH 17/51] test(channel-weights): use semantic queued-estimator fixtures --- ...est_estimate_llm_channel_weights_script.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/test_estimate_llm_channel_weights_script.py b/tests/test_estimate_llm_channel_weights_script.py index 210c95d84..44b1bcdb4 100644 --- a/tests/test_estimate_llm_channel_weights_script.py +++ b/tests/test_estimate_llm_channel_weights_script.py @@ -17,21 +17,24 @@ def test_batch_requests_carry_caller_custom_ids_for_every_pair() -> None: - labels = [("a", "b"), ("c", "d"), ("e", "f")] - requests = script.batch_requests_for_pairs([0, 2], labels) - assert [request["custom_id"] for request in requests] == ["pair-0", "pair-2"] + candidate_pair_labels = [("a", "b"), ("c", "d"), ("e", "f")] + batch_requests = script.batch_requests_for_pairs([0, 2], candidate_pair_labels) + assert [batch_request["custom_id"] for batch_request in batch_requests] == [ + "pair-0", + "pair-2", + ] # Never mix caller ids with generated ids in one batch (upstream # guidance on contextual-orchestrator #832): every request has one. - assert all("custom_id" in request for request in requests) - assert requests[0]["messages"][0]["content"] == judge_prompt("a", "b") - assert requests[1]["messages"][0]["content"] == judge_prompt("e", "f") - assert all(request["mode"] == "auto" for request in requests) + assert all("custom_id" in batch_request for batch_request in batch_requests) + assert batch_requests[0]["messages"][0]["content"] == judge_prompt("a", "b") + assert batch_requests[1]["messages"][0]["content"] == judge_prompt("e", "f") + assert all(batch_request["mode"] == "auto" for batch_request in batch_requests) def test_shared_judge_prompt_and_confidence_parse_round_trip() -> None: - prompt = judge_prompt("Record about pricing", "Follow-up record") - assert "Record A: Record about pricing" in prompt - assert "Record B: Follow-up record" in prompt + judgment_prompt = judge_prompt("Record about pricing", "Follow-up record") + assert "Record A: Record about pricing" in judgment_prompt + assert "Record B: Follow-up record" in judgment_prompt assert parse_confidence("0.85") == 0.85 assert parse_confidence("confidence: 0.4 maybe") == 0.4 with pytest.raises(HttpClientError): @@ -44,7 +47,7 @@ def test_errored_judgments_stay_unjudged_instead_of_becoming_zero() -> None: 0.0 -- the pair stays unjudged and the incomplete-run path reports it. Mapping is by custom_id only; foreign or malformed ids are ignored. """ - updates = script.judgment_updates_from_results( + judgment_updates = script.judgment_updates_from_results( [ {"custom_id": "pair-3", "answer": "0.7"}, {"custom_id": "pair-4", "answer": ""}, @@ -54,7 +57,7 @@ def test_errored_judgments_stay_unjudged_instead_of_becoming_zero() -> None: {"custom_id": "pair-not-a-number", "answer": "0.9"}, ] ) - assert updates == [(3, 0.7), (6, 0.0)] + assert judgment_updates == [(3, 0.7), (6, 0.0)] def test_batch_completion_is_detected_from_flag_or_status() -> None: From 44b84ad0f7dedb9f3bb5c5448ea92083d68c0bb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:27:02 +0900 Subject: [PATCH 18/51] docs(channel-weights): record queued-estimator vocabulary --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4033ebc03..a3f4e58f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,11 @@ All notable changes to this project are documented here. Format follows contracts. The fast-mlsirm `v0.9.1` consumer cutover remains isolated in #967. +- The queued LLM channel-weight estimator now uses semantic batch-submission, + estimation-run, pair-judgment, orchestrator, and command identifiers while + preserving provider payload fields, CLI and JSON contracts, SQL, transaction + boundaries, and incomplete-judgment behavior. + - The bounded thread-group-key backfill now uses semantic command, database, record, and count identifiers while preserving `--dry-run`, aggregate JSON, SQL, transaction rollback, and persistence behavior. From 64a79ea06c9471f37ebba7f1def68a45212a9f58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:27:11 +0900 Subject: [PATCH 19/51] docs(gaps): add queued-estimator naming evidence --- docs/product-technical-gap-baseline.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b3404a730..0931d04f4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -12,6 +12,16 @@ > dependency change. Status: implementation, behavioral tests, and AST > regression GREEN locally; GitHub exact-head verification pending. +> Queued LLM channel-weight estimator overlay: the ADR 0200 batch adapter on +> the same protected head used generic owned submit/collect, database, run, +> result, score, and command identifiers (`_submit`, `_collect`, `conn`, `run`, +> `results`, `score`, `args`). Action: translate the private operator surface to +> batch-estimation, estimation-run, pair-judgment, and orchestrator language +> while preserving provider request/response fields, CLI flags, JSON output, +> SQL, transaction boundaries, and fail-closed incomplete judgment behavior. +> Status: implementation, behavioral tests, and AST regression GREEN locally; +> GitHub exact-head verification pending. + > Exact-head naming overlay: 2026-09-07 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded post-Keyman > operator still used generic package-owned command, database, record, and From 4acd000164597b3ddfdecf2d69fc088df17955e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:24:31 +0900 Subject: [PATCH 20/51] test(catalog-sync): require semantic operator identifiers --- ...c_occupational_construct_catalog_naming.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_sync_occupational_construct_catalog_naming.py diff --git a/tests/test_sync_occupational_construct_catalog_naming.py b/tests/test_sync_occupational_construct_catalog_naming.py new file mode 100644 index 000000000..16e17970b --- /dev/null +++ b/tests/test_sync_occupational_construct_catalog_naming.py @@ -0,0 +1,69 @@ +"""Naming and boundary contracts for the occupational catalog synchronizer.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/sync_occupational_construct_catalog.py") + + +def test_catalog_sync_uses_semantic_owned_identifiers() -> None: + """Keep command, database, payload, and result names domain-specific.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_parser", + "args", + "conn", + "count", + "payload", + "settings", + "synchronize_catalog", + } + ) + assert { + "_catalog_sync_parser", + "catalog_payload", + "command_arguments", + "database_connection", + "runtime_settings", + "synchronize_occupational_construct_catalog", + "synchronized_construct_count", + } <= owned_identifiers + + +def test_catalog_sync_preserves_cli_and_output_contracts() -> None: + """Keep the operator flag, release, and result keys at the boundary.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"--target-dsn"', + '"release"', + '"31.0"', + '"construct_count"', + ): + assert contract_literal in script_source + assert ( + "asyncio.run(\n" + " synchronize_occupational_construct_catalog(" + in script_source + ) From 56e5d3a21524411b20cb6028b1a4cecc2e423c26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:24:48 +0900 Subject: [PATCH 21/51] refactor(catalog-sync): use semantic operator identifiers --- .../sync_occupational_construct_catalog.py | 30 +++++++++++-------- ...c_occupational_construct_catalog_naming.py | 3 +- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/scripts/sync_occupational_construct_catalog.py b/scripts/sync_occupational_construct_catalog.py index 412d8d484..4ac62f8cd 100644 --- a/scripts/sync_occupational_construct_catalog.py +++ b/scripts/sync_occupational_construct_catalog.py @@ -21,18 +21,18 @@ ) -def _parser() -> argparse.ArgumentParser: +def _catalog_sync_parser() -> argparse.ArgumentParser: """Build the operator-only catalog synchronization parser.""" - parser = argparse.ArgumentParser( + catalog_sync_parser = argparse.ArgumentParser( description="Synchronize the governed O*NET occupational construct catalog." ) - parser.add_argument("--target-dsn") - return parser + catalog_sync_parser.add_argument("--target-dsn") + return catalog_sync_parser -async def synchronize_catalog(target_dsn: str) -> int: +async def synchronize_occupational_construct_catalog(target_dsn: str) -> int: """Download the fixed release and persist it without exposing credentials.""" - payload = await asyncio.to_thread( + catalog_payload = await asyncio.to_thread( get_json, ONET_CONTENT_MODEL_URL, timeout=30.0, @@ -40,19 +40,23 @@ async def synchronize_catalog(target_dsn: str) -> int: maximum_response_bytes=8 * 1024 * 1024, expected_response_media_type="application/json", ) - conn = await asyncpg.connect(target_dsn) + database_connection = await asyncpg.connect(target_dsn) try: - return await sync_onet_construct_catalog(conn, payload) + return await sync_onet_construct_catalog(database_connection, catalog_payload) finally: - await conn.close() + await database_connection.close() def main() -> None: """Parse configuration, synchronize the catalog, and print only its count.""" - args = _parser().parse_args() - settings = load_settings() - count = asyncio.run(synchronize_catalog(args.target_dsn or settings.database_url)) - print({"release": "31.0", "construct_count": count}) + command_arguments = _catalog_sync_parser().parse_args() + runtime_settings = load_settings() + synchronized_construct_count = asyncio.run( + synchronize_occupational_construct_catalog( + command_arguments.target_dsn or runtime_settings.database_url + ) + ) + print({"release": "31.0", "construct_count": synchronized_construct_count}) if __name__ == "__main__": diff --git a/tests/test_sync_occupational_construct_catalog_naming.py b/tests/test_sync_occupational_construct_catalog_naming.py index 16e17970b..e6a25cd16 100644 --- a/tests/test_sync_occupational_construct_catalog_naming.py +++ b/tests/test_sync_occupational_construct_catalog_naming.py @@ -64,6 +64,5 @@ def test_catalog_sync_preserves_cli_and_output_contracts() -> None: assert contract_literal in script_source assert ( "asyncio.run(\n" - " synchronize_occupational_construct_catalog(" - in script_source + " synchronize_occupational_construct_catalog(" in script_source ) From 72f91c3e7297d92a186fb429641496dfdff1d0b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:25:03 +0900 Subject: [PATCH 22/51] docs(gaps): add catalog-sync naming evidence --- CHANGELOG.md | 5 +++++ docs/product-technical-gap-baseline.md | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f4e58f2..020f3001b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The ADR 0250 occupational catalog synchronization command now uses semantic + catalog, database, payload, configuration, and result identifiers while + preserving the fixed O*NET URL, `--target-dsn`, release/result output, digest + validation, transactional UPSERT, and connection-close behavior. + - The deterministic channel-weight estimator now uses semantic source-post, candidate-window, database, estimate, and command identifiers while preserving pair sampling, fitting, CLI, JSON, SQL, and persisted provenance diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0931d04f4..b55910697 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,16 @@ # Product & Technical Gap Baseline +> Occupational catalog synchronizer naming overlay: 2026-09-07 KST. Protected +> `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The ADR 0250 +> operator used generic package-owned command, database, payload, +> configuration, and result identifiers (`_parser`, `synchronize_catalog`, +> `args`, `conn`, `payload`, `settings`, `count`). Action: translate that +> complete private caller surface to occupational-catalog language while +> preserving the fixed O*NET release URL, CLI flag, output keys, digest gate, +> transactional UPSERT, and connection close. Status: RED naming/contract +> regression followed by production GREEN locally; GitHub exact-head checks +> and independent review remain pending. + > Deterministic channel-weight estimator naming overlay: 2026-09-07 KST. > Protected `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. > The ADR 0200 operator used generic package-owned sampling, database, From 6982746bd1be37b2599b9fbba92dcffef566dd30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:21:24 +0900 Subject: [PATCH 23/51] test(queue-backfill): require semantic operator identifiers --- ...t_content_backfill_semantic_identifiers.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_queue_post_content_backfill_semantic_identifiers.py diff --git a/tests/test_queue_post_content_backfill_semantic_identifiers.py b/tests/test_queue_post_content_backfill_semantic_identifiers.py new file mode 100644 index 000000000..4391947f4 --- /dev/null +++ b/tests/test_queue_post_content_backfill_semantic_identifiers.py @@ -0,0 +1,81 @@ +"""Naming and boundary contracts for the post-content queue backfill.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/queue_post_content_backfill.py") + + +def test_queue_backfill_uses_semantic_owned_identifiers() -> None: + """Keep command, database, queue, record, and result names specific.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_parser", + "args", + "client", + "complete", + "connection", + "request", + "result", + "row", + "rows", + "settings", + } + ) + assert { + "_queue_backfill_parser", + "backfill_summary", + "command_arguments", + "database_connection", + "post_content_complete", + "post_content_job_request", + "runtime_settings", + "source_post_record", + "source_post_records", + "valkey_client", + } <= owned_identifiers + + +def test_queue_backfill_preserves_cli_result_sql_and_publish_contracts() -> None: + """Keep public operator inputs, outputs, persistence, and event calls stable.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"--target-dsn"', + '"--valkey-url"', + '"--limit"', + '"--all"', + '"scanned_posts"', + '"already_complete"', + '"queued_posts"', + '"published_events"', + "from source_post", + "post_content_unit", + "post_content_embedding", + "post_content_image_region_embedding", + "ensure_post_content_job(", + "publish_post_content_event(", + ): + assert contract_literal in script_source + assert "asyncio.run(\n queue_post_content_backfill(" in script_source From 8f03f6d4b082562807e2a67d26d92527f1f9bdb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:21:38 +0900 Subject: [PATCH 24/51] test(queue-backfill): cover parser and post limit names --- tests/test_queue_post_content_backfill_semantic_identifiers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_queue_post_content_backfill_semantic_identifiers.py b/tests/test_queue_post_content_backfill_semantic_identifiers.py index 4391947f4..f4badde28 100644 --- a/tests/test_queue_post_content_backfill_semantic_identifiers.py +++ b/tests/test_queue_post_content_backfill_semantic_identifiers.py @@ -36,6 +36,8 @@ def test_queue_backfill_uses_semantic_owned_identifiers() -> None: "client", "complete", "connection", + "limit", + "parser", "request", "result", "row", @@ -48,6 +50,7 @@ def test_queue_backfill_uses_semantic_owned_identifiers() -> None: "backfill_summary", "command_arguments", "database_connection", + "post_limit", "post_content_complete", "post_content_job_request", "runtime_settings", From 78e8d85b7c93f001d3b7ab74a954f2d168b2913c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:22:01 +0900 Subject: [PATCH 25/51] refactor(queue-backfill): use semantic operator identifiers --- scripts/queue_post_content_backfill.py | 150 +++++++++++++------------ 1 file changed, 81 insertions(+), 69 deletions(-) diff --git a/scripts/queue_post_content_backfill.py b/scripts/queue_post_content_backfill.py index bac966ddc..e481a2946 100644 --- a/scripts/queue_post_content_backfill.py +++ b/scripts/queue_post_content_backfill.py @@ -24,45 +24,54 @@ from backend.app.config import load_settings # noqa: E402 -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( +def _queue_backfill_parser() -> argparse.ArgumentParser: + """Build the post-content queue backfill command parser.""" + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( "--target-dsn", default=os.environ.get( "DATABASE_URL", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave", ), ) - parser.add_argument( + argument_parser.add_argument( "--valkey-url", default=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), ) - parser.add_argument("--limit", type=int, default=100) - parser.add_argument("--all", action="store_true", help="scan the complete real corpus") - return parser + argument_parser.add_argument("--limit", type=int, default=100) + argument_parser.add_argument( + "--all", action="store_true", help="scan the complete real corpus" + ) + return argument_parser async def queue_post_content_backfill( target_dsn: str, valkey_url: str, *, - limit: int | None, + post_limit: int | None, ) -> dict[str, int]: - if limit is not None and limit < 1: + """Queue incomplete post-content work and return aggregate counts.""" + if post_limit is not None and post_limit < 1: raise ValueError("limit must be positive") - settings = load_settings() + runtime_settings = load_settings() require_orchestrator_evidence = bool( - settings.orchestrator_base_url and settings.orchestrator_api_key + runtime_settings.orchestrator_base_url and runtime_settings.orchestrator_api_key ) - connection = await asyncpg.connect(target_dsn) - client = redis.from_url(valkey_url, decode_responses=True) - result = {"scanned_posts": 0, "already_complete": 0, "queued_posts": 0, "published_events": 0} + database_connection = await asyncpg.connect(target_dsn) + valkey_client = redis.from_url(valkey_url, decode_responses=True) + backfill_summary = { + "scanned_posts": 0, + "already_complete": 0, + "queued_posts": 0, + "published_events": 0, + } try: - rows = await connection.fetch( + source_post_records = await database_connection.fetch( """ select post_id, post_body - from source_post post + from source_post source_record where nullif(btrim(source_draft_code), '') is null and nullif(btrim(source_deleted_flag), '') is null and ( @@ -82,95 +91,98 @@ async def queue_post_content_backfill( and ( not exists ( select 1 - from post_content_unit unit - where unit.post_id = post.post_id + from post_content_unit content_unit + where content_unit.post_id = source_record.post_id ) or ($1::boolean and exists ( select 1 - from post_content_unit unit - left join post_content_embedding embedding - on embedding.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = post.post_id - and embedding.post_content_embedding_id is null + from post_content_unit content_unit + left join post_content_embedding content_embedding + on content_embedding.post_content_unit_id = content_unit.post_content_unit_id + where content_unit.post_id = source_record.post_id + and content_embedding.post_content_embedding_id is null )) or ($1::boolean and exists ( select 1 - from post_content_unit unit - join post_content_image image - on image.post_content_unit_id = unit.post_content_unit_id - join post_content_image_region region - on region.post_content_image_id = image.post_content_image_id - left join post_content_image_region_embedding embedding - on embedding.post_content_image_region_id = region.post_content_image_region_id - where unit.post_id = post.post_id - and region.description_status_code = 'described' - and embedding.post_content_image_region_embedding_id is null + from post_content_unit content_unit + join post_content_image content_image + on content_image.post_content_unit_id = content_unit.post_content_unit_id + join post_content_image_region image_region + on image_region.post_content_image_id = content_image.post_content_image_id + left join post_content_image_region_embedding region_embedding + on region_embedding.post_content_image_region_id = image_region.post_content_image_region_id + where content_unit.post_id = source_record.post_id + and image_region.description_status_code = 'described' + and region_embedding.post_content_image_region_embedding_id is null )) or ($2::boolean and exists ( select 1 - from post_content_unit unit - left join post_content_unit_structure structure - on structure.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = post.post_id - and unit.unit_kind_code <> 'image' + from post_content_unit content_unit + left join post_content_unit_structure unit_structure + on unit_structure.post_content_unit_id = content_unit.post_content_unit_id + where content_unit.post_id = source_record.post_id + and content_unit.unit_kind_code <> 'image' and ( - structure.post_content_unit_structure_id is null - or structure.decision_source_code = 'unresolved' + unit_structure.post_content_unit_structure_id is null + or unit_structure.decision_source_code = 'unresolved' ) )) ) - order by post.created_at, post.post_id + order by source_record.created_at, source_record.post_id limit $3::bigint """, require_orchestrator_evidence, require_orchestrator_evidence, - limit if limit is not None else 9223372036854775807, + post_limit if post_limit is not None else 9223372036854775807, ) - for row in rows: - result["scanned_posts"] += 1 - post_id = str(row["post_id"]) - async with connection.transaction(): - complete = await post_content_is_complete( - connection, + for source_post_record in source_post_records: + backfill_summary["scanned_posts"] += 1 + post_id = str(source_post_record["post_id"]) + async with database_connection.transaction(): + post_content_complete = await post_content_is_complete( + database_connection, post_id, require_embedding=require_orchestrator_evidence, require_structure=require_orchestrator_evidence, ) - request = await ensure_post_content_job( - connection, + post_content_job_request = await ensure_post_content_job( + database_connection, post_id, - str(row["post_body"] or ""), - content_complete=complete, + str(source_post_record["post_body"] or ""), + content_complete=post_content_complete, ) - if complete and not request.should_publish: - result["already_complete"] += 1 + if post_content_complete and not post_content_job_request.should_publish: + backfill_summary["already_complete"] += 1 continue - if request.should_publish: + if post_content_job_request.should_publish: entry_id = await publish_post_content_event( - client, + valkey_client, post_id=post_id, - source_body_digest=request.source_body_sha256, + source_body_digest=post_content_job_request.source_body_sha256, ) if entry_id is None: - raise RuntimeError(f"Valkey did not publish post-content job {post_id}") - result["published_events"] += 1 - result["queued_posts"] += 1 - return result + raise RuntimeError( + f"Valkey did not publish post-content job {post_id}" + ) + backfill_summary["published_events"] += 1 + backfill_summary["queued_posts"] += 1 + return backfill_summary finally: - await connection.close() - await client.aclose() + await database_connection.close() + await valkey_client.aclose() def main() -> None: - args = _parser().parse_args() - result = asyncio.run( + """Run the post-content queue backfill command.""" + command_arguments = _queue_backfill_parser().parse_args() + backfill_summary = asyncio.run( queue_post_content_backfill( - args.target_dsn, - args.valkey_url, - limit=None if args.all else args.limit, + command_arguments.target_dsn, + command_arguments.valkey_url, + post_limit=None if command_arguments.all else command_arguments.limit, ) ) - print(result) + print(backfill_summary) if __name__ == "__main__": From 872a46831bf499e880d97ee2e423485e088b2add Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:22:20 +0900 Subject: [PATCH 26/51] docs(gaps): add queue-backfill naming evidence --- CHANGELOG.md | 5 +++++ docs/product-technical-gap-baseline.md | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 020f3001b..7205c3c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The post-content queue backfill now uses semantic command, database, Valkey, + source-record, job-request, and aggregate-result identifiers while preserving + CLI flags, JSON result keys, selection SQL, transaction boundaries, and event + publication behavior. + - The ADR 0250 occupational catalog synchronization command now uses semantic catalog, database, payload, configuration, and result identifiers while preserving the fixed O*NET URL, `--target-dsn`, release/result output, digest diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b55910697..5b94cc06a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,17 @@ # Product & Technical Gap Baseline +> Post-content queue backfill naming overlay: 2026-09-07 KST. Protected +> `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The private +> operator used generic package-owned command, database, queue, record, request, +> and result identifiers (`_parser`, `args`, `connection`, `client`, `rows`, +> `row`, `complete`, `request`, `result`, `settings`, `limit`) plus generic SQL +> aliases. Action: translate the complete repository-local surface to +> post-content queue language while preserving CLI flags, JSON result keys, +> source-selection SQL semantics, transaction boundaries, Valkey publication, +> and resource close behavior. Status: RED naming/contract regression followed +> by production GREEN locally; GitHub exact-head checks and independent review +> remain pending. + > Occupational catalog synchronizer naming overlay: 2026-09-07 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The ADR 0250 > operator used generic package-owned command, database, payload, From 52443f8fdce26054c9c3783adfcff933e342bf0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:22:38 +0900 Subject: [PATCH 27/51] test(backfill): require semantic post-content identifiers --- ...kfill_post_content_semantic_identifiers.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/test_backfill_post_content_semantic_identifiers.py diff --git a/tests/test_backfill_post_content_semantic_identifiers.py b/tests/test_backfill_post_content_semantic_identifiers.py new file mode 100644 index 000000000..1a5fc36ae --- /dev/null +++ b/tests/test_backfill_post_content_semantic_identifiers.py @@ -0,0 +1,104 @@ +"""Naming and boundary contracts for the synchronous post-content backfill.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/backfill_post_content.py") + + +def test_post_content_backfill_uses_semantic_owned_identifiers() -> None: + """Keep command, database, record, image, and result names specific.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_parser", + "args", + "conn", + "item", + "limit", + "parser", + "result", + "row", + "rows", + } + ) + assert { + "_post_content_backfill_parser", + "argument_parser", + "backfill_summary", + "command_arguments", + "database_connection", + "described_image_count", + "image_result", + "normalized_post_content", + "post_limit", + "selected_post_record", + "selected_post_records", + "source_post_record", + } <= owned_identifiers + + +def test_post_content_backfill_preserves_operator_contracts() -> None: + """Keep CLI, aggregate output, persistence, and close contracts stable.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"--target-dsn"', + '"--post-id"', + '"--limit"', + '"--all"', + '"--normalize-only"', + '"requested_posts"', + '"selected_posts"', + '"processed_posts"', + '"described_posts"', + '"described_images"', + '"described_regions"', + '"embedding_rows"', + '"skipped_posts"', + "from source_post", + "post_content_unit", + "post_content_embedding", + "persist_post_content(", + "record_post_content_backfill_success(", + "await database_connection.close()", + ): + assert contract_literal in script_source + + for generic_sql_alias in ( + "source_post post", + "source_post real_post", + "post_content_unit unit", + "post_content_embedding embedding", + "corporate_entity entity", + ): + assert generic_sql_alias not in script_source + + for semantic_sql_alias in ( + "source_post source_record", + "source_post attributed_post", + "post_content_unit content_unit", + "post_content_embedding content_embedding", + "corporate_entity owning_entity", + ): + assert semantic_sql_alias in script_source From 7cefc9690b6515c63cc7a86af9263bb09d3f8373 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:27:18 +0900 Subject: [PATCH 28/51] refactor(backfill): name post-content execution context --- CHANGELOG.md | 5 + docs/product-technical-gap-baseline.md | 11 + scripts/backfill_post_content.py | 254 ++++++++++-------- ...kfill_post_content_semantic_identifiers.py | 1 - 4 files changed, 158 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7205c3c39..63e8e1ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The synchronous post-content backfill now uses semantic command, database, + source-post, normalized-content, image-result, aggregate-result, and SQL + aliases while preserving CLI flags, JSON result keys, source selection, + persistence, transaction, and connection-close behavior. + - The post-content queue backfill now uses semantic command, database, Valkey, source-record, job-request, and aggregate-result identifiers while preserving CLI flags, JSON result keys, selection SQL, transaction boundaries, and event diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5b94cc06a..c0beb0e7d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,16 @@ # Product & Technical Gap Baseline +> Synchronous post-content backfill naming overlay: 2026-09-07 KST. Protected +> `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The operator used +> generic package-owned command, database, record, normalized-content, image, +> result, and limit identifiers (`_parser`, `args`, `conn`, `row`, `item`, +> `result`, `limit`) plus generic SQL aliases. Action: translate the complete +> private caller surface to post-content backfill language while preserving CLI +> flags, JSON result keys, source-selection semantics, persistence and +> transaction boundaries, and connection close. Status: RED naming/contract +> regression followed by production GREEN locally; GitHub exact-head checks and +> independent review remain pending. + > Post-content queue backfill naming overlay: 2026-09-07 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The private > operator used generic package-owned command, database, queue, record, request, diff --git a/scripts/backfill_post_content.py b/scripts/backfill_post_content.py index 8fd9cda8c..69b8b21ba 100644 --- a/scripts/backfill_post_content.py +++ b/scripts/backfill_post_content.py @@ -23,45 +23,56 @@ sys.path.insert(0, str(REPOSITORY_ROOT)) from backend.app.post_content_queue import record_post_content_backfill_success -from lineageweave.embedding_client import NullEmbeddingClient, orchestrator_embedding_client -from lineageweave.image_content import NullImageContentClient, orchestrator_vision_client +from lineageweave.embedding_client import ( + NullEmbeddingClient, + orchestrator_embedding_client, +) +from lineageweave.image_content import ( + NullImageContentClient, + orchestrator_vision_client, +) from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_content_persistence import persist_post_content -from lineageweave.post_structure import ContextualOrchestratorPostStructureClient, NullPostStructureClient +from lineageweave.post_structure import ( + ContextualOrchestratorPostStructureClient, + NullPostStructureClient, +) -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( +def _post_content_backfill_parser() -> argparse.ArgumentParser: + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( "--target-dsn", default=os.environ.get( "DATABASE_URL", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave", ), ) - parser.add_argument("--post-id", action="append", dest="post_ids") - parser.add_argument("--limit", type=int, default=5) - parser.add_argument( + argument_parser.add_argument("--post-id", action="append", dest="post_ids") + argument_parser.add_argument("--limit", type=int, default=5) + argument_parser.add_argument( "--all", action="store_true", help="process every eligible post without persisted content units", ) - parser.add_argument( + argument_parser.add_argument( "--normalize-only", action="store_true", help="persist deterministic DOM/text units without VISION, structure, or embedding calls", ) - return parser + return argument_parser async def backfill_post_content( target_dsn: str, raw_post_ids: list[str] | None, - limit: int | None, + post_limit: int | None, normalize_only: bool = False, ) -> dict[str, int]: - post_ids = [str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or [])] + post_ids = [ + str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or []) + ] if normalize_only: vision_client = NullImageContentClient() embedding_client = NullEmbeddingClient() @@ -74,7 +85,9 @@ async def backfill_post_content( orchestrator_api_key, ) if not vision_client.available: - raise RuntimeError("VISION is unavailable; configure contextual-orchestrator before backfill") + raise RuntimeError( + "VISION is unavailable; configure contextual-orchestrator before backfill" + ) embedding_client = orchestrator_embedding_client( orchestrator_base_url, @@ -85,56 +98,58 @@ async def backfill_post_content( "embedding is unavailable; configure contextual-orchestrator before backfill" ) structure_client = ( - ContextualOrchestratorPostStructureClient(orchestrator_base_url, orchestrator_api_key) + ContextualOrchestratorPostStructureClient( + orchestrator_base_url, orchestrator_api_key + ) if orchestrator_base_url and orchestrator_api_key else NullPostStructureClient() ) - conn = await asyncpg.connect(target_dsn) + database_connection = await asyncpg.connect(target_dsn) try: - selected_rows = await conn.fetch( + selected_post_records = await database_connection.fetch( """ - select post.post_id - from source_post post - where nullif(btrim(post.source_draft_code), '') is null - and nullif(btrim(post.source_deleted_flag), '') is null + select source_record.post_id + from source_post source_record + where nullif(btrim(source_record.source_draft_code), '') is null + and nullif(btrim(source_record.source_deleted_flag), '') is null and not ( ( - nullif(btrim(post.source_author_code), '') is null - and nullif(btrim(post.source_author_name), '') is null - and nullif(btrim(post.source_company_code), '') is null - and nullif(btrim(post.source_company_name), '') is null - and nullif(btrim(post.source_process_unit_code), '') is null - and nullif(btrim(post.source_process_unit_name), '') is null - and nullif(btrim(post.source_sales_pool_code), '') is null - and nullif(btrim(post.source_sales_pool_name), '') is null - and nullif(btrim(post.source_customer_code), '') is null - and nullif(btrim(post.source_customer_name), '') is null - and nullif(btrim(post.source_project_code), '') is null - and nullif(btrim(post.source_project_name), '') is null + nullif(btrim(source_record.source_author_code), '') is null + and nullif(btrim(source_record.source_author_name), '') is null + and nullif(btrim(source_record.source_company_code), '') is null + and nullif(btrim(source_record.source_company_name), '') is null + and nullif(btrim(source_record.source_process_unit_code), '') is null + and nullif(btrim(source_record.source_process_unit_name), '') is null + and nullif(btrim(source_record.source_sales_pool_code), '') is null + and nullif(btrim(source_record.source_sales_pool_name), '') is null + and nullif(btrim(source_record.source_customer_code), '') is null + and nullif(btrim(source_record.source_customer_name), '') is null + and nullif(btrim(source_record.source_project_code), '') is null + and nullif(btrim(source_record.source_project_name), '') is null ) and exists ( select 1 - from source_post real_post + from source_post attributed_post where ( - nullif(btrim(real_post.source_author_code), '') is not null - or nullif(btrim(real_post.source_author_name), '') is not null - or nullif(btrim(real_post.source_company_code), '') is not null - or nullif(btrim(real_post.source_company_name), '') is not null - or nullif(btrim(real_post.source_process_unit_code), '') is not null - or nullif(btrim(real_post.source_process_unit_name), '') is not null - or nullif(btrim(real_post.source_sales_pool_code), '') is not null - or nullif(btrim(real_post.source_sales_pool_name), '') is not null - or nullif(btrim(real_post.source_customer_code), '') is not null - or nullif(btrim(real_post.source_customer_name), '') is not null - or nullif(btrim(real_post.source_project_code), '') is not null - or nullif(btrim(real_post.source_project_name), '') is not null + nullif(btrim(attributed_post.source_author_code), '') is not null + or nullif(btrim(attributed_post.source_author_name), '') is not null + or nullif(btrim(attributed_post.source_company_code), '') is not null + or nullif(btrim(attributed_post.source_company_name), '') is not null + or nullif(btrim(attributed_post.source_process_unit_code), '') is not null + or nullif(btrim(attributed_post.source_process_unit_name), '') is not null + or nullif(btrim(attributed_post.source_sales_pool_code), '') is not null + or nullif(btrim(attributed_post.source_sales_pool_name), '') is not null + or nullif(btrim(attributed_post.source_customer_code), '') is not null + or nullif(btrim(attributed_post.source_customer_name), '') is not null + or nullif(btrim(attributed_post.source_project_code), '') is not null + or nullif(btrim(attributed_post.source_project_name), '') is not null ) ) ) and ( ( $1::uuid[] is not null - and post.post_id = any($1::uuid[]) + and source_record.post_id = any($1::uuid[]) ) or ( $1::uuid[] is null @@ -143,8 +158,8 @@ async def backfill_post_content( $2::boolean and not exists ( select 1 - from post_content_unit unit - where unit.post_id = post.post_id + from post_content_unit content_unit + where content_unit.post_id = source_record.post_id ) ) or ( @@ -152,35 +167,35 @@ async def backfill_post_content( and ( not exists ( select 1 - from post_content_unit unit - where unit.post_id = post.post_id + from post_content_unit content_unit + where content_unit.post_id = source_record.post_id ) or exists ( select 1 - from post_content_unit unit - left join post_content_embedding embedding - on embedding.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = post.post_id - and embedding.post_content_unit_id is null + from post_content_unit content_unit + left join post_content_embedding content_embedding + on content_embedding.post_content_unit_id = content_unit.post_content_unit_id + where content_unit.post_id = source_record.post_id + and content_embedding.post_content_unit_id is null ) ) ) ) ) ) - order by post.created_at, post.post_id + order by source_record.created_at, source_record.post_id limit $3::bigint """, post_ids or None, normalize_only, - limit, + post_limit, ) - if post_ids and len(selected_rows) != len(post_ids): + if post_ids and len(selected_post_records) != len(post_ids): raise ValueError("one or more requested post IDs were not found") - result = { + backfill_summary = { "requested_posts": len(post_ids), - "selected_posts": len(selected_rows), + "selected_posts": len(selected_post_records), "processed_posts": 0, "described_posts": 0, "described_images": 0, @@ -188,85 +203,100 @@ async def backfill_post_content( "embedding_rows": 0, "skipped_posts": 0, } - for selected_row in selected_rows: - row = await conn.fetchrow( + for selected_post_record in selected_post_records: + source_post_record = await database_connection.fetchrow( """ - select post.post_id, post.post_title, post.post_body, post.author_account_id, - post.source_process_unit_code, post.source_author_code, - post.source_company_code, post.source_customer_code, - post.source_project_code, post.source_sales_pool_code, - entity.corporate_entity_code - from source_post post - left join corporate_entity entity - on entity.corporate_entity_id = post.corporate_entity_id - where post.post_id = $1 + select source_record.post_id, source_record.post_title, + source_record.post_body, source_record.author_account_id, + source_record.source_process_unit_code, source_record.source_author_code, + source_record.source_company_code, source_record.source_customer_code, + source_record.source_project_code, source_record.source_sales_pool_code, + owning_entity.corporate_entity_code + from source_post source_record + left join corporate_entity owning_entity + on owning_entity.corporate_entity_id = source_record.corporate_entity_id + where source_record.post_id = $1 """, - selected_row["post_id"], + selected_post_record["post_id"], ) - if row is None: + if source_post_record is None: continue - with use_llm_metadata(build_post_llm_metadata(str(row["post_id"]), row)): - normalized = normalize_post_body(row["post_body"], vision_client=vision_client) - described_images = sum( - item.status_code == "described" for item in normalized.image_results + with use_llm_metadata( + build_post_llm_metadata( + str(source_post_record["post_id"]), source_post_record ) - if described_images == 0 and not normalized.text.strip(): - result["skipped_posts"] += 1 + ): + normalized_post_content = normalize_post_body( + source_post_record["post_body"], vision_client=vision_client + ) + described_image_count = sum( + image_result.status_code == "described" + for image_result in normalized_post_content.image_results + ) + if ( + described_image_count == 0 + and not normalized_post_content.text.strip() + ): + backfill_summary["skipped_posts"] += 1 continue await persist_post_content( - conn, - str(row["post_id"]), - row["post_body"], + database_connection, + str(source_post_record["post_id"]), + source_post_record["post_body"], vision_client=vision_client, embedding_client=embedding_client, - normalized_result=normalized, + normalized_result=normalized_post_content, structure_client=structure_client, - post_title=row["post_title"], + post_title=source_post_record["post_title"], ) - async with conn.transaction(): + async with database_connection.transaction(): await record_post_content_backfill_success( - conn, - str(row["post_id"]), - str(row["post_body"] or ""), + database_connection, + str(source_post_record["post_id"]), + str(source_post_record["post_body"] or ""), ) - result["processed_posts"] += 1 - if described_images: - result["described_posts"] += 1 - result["described_images"] += described_images - result["described_regions"] += sum( - len(item.regions) - for item in normalized.image_results - if item.status_code == "described" + backfill_summary["processed_posts"] += 1 + if described_image_count: + backfill_summary["described_posts"] += 1 + backfill_summary["described_images"] += described_image_count + backfill_summary["described_regions"] += sum( + len(image_result.regions) + for image_result in normalized_post_content.image_results + if image_result.status_code == "described" ) - result["embedding_rows"] += await conn.fetchval( + backfill_summary["embedding_rows"] += await database_connection.fetchval( """ select count(*) - from post_content_embedding embedding - join post_content_unit unit using (post_content_unit_id) - where unit.post_id = $1 + from post_content_embedding content_embedding + join post_content_unit content_unit using (post_content_unit_id) + where content_unit.post_id = $1 """, - row["post_id"], + source_post_record["post_id"], ) - return result + return backfill_summary finally: - await conn.close() + await database_connection.close() def main() -> None: - args = _parser().parse_args() - if args.limit < 1: + command_arguments = _post_content_backfill_parser().parse_args() + if command_arguments.limit < 1: raise SystemExit("--limit must be positive") - if args.all and args.post_ids: + if command_arguments.all and command_arguments.post_ids: raise SystemExit("--all cannot be combined with --post-id") - limit = None if args.all or args.post_ids else args.limit + post_limit = ( + None + if command_arguments.all or command_arguments.post_ids + else command_arguments.limit + ) print( json.dumps( asyncio.run( backfill_post_content( - args.target_dsn, - args.post_ids, - limit, - args.normalize_only, + command_arguments.target_dsn, + command_arguments.post_ids, + post_limit, + command_arguments.normalize_only, ) ), sort_keys=True, diff --git a/tests/test_backfill_post_content_semantic_identifiers.py b/tests/test_backfill_post_content_semantic_identifiers.py index 1a5fc36ae..9b7bac80d 100644 --- a/tests/test_backfill_post_content_semantic_identifiers.py +++ b/tests/test_backfill_post_content_semantic_identifiers.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - SCRIPT_PATH = Path("scripts/backfill_post_content.py") From fd99aa534751df2deb68ec77a86bb98df167aad1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:26:04 +0900 Subject: [PATCH 29/51] test(backfill): require semantic post-summary identifiers --- ...ill_post_summaries_semantic_identifiers.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_backfill_post_summaries_semantic_identifiers.py diff --git a/tests/test_backfill_post_summaries_semantic_identifiers.py b/tests/test_backfill_post_summaries_semantic_identifiers.py new file mode 100644 index 000000000..9434aafcc --- /dev/null +++ b/tests/test_backfill_post_summaries_semantic_identifiers.py @@ -0,0 +1,110 @@ +"""Contract tests for semantic post-summary backfill identifiers.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SOURCE_PATH = Path(__file__).parents[1] / "scripts" / "backfill_post_summaries.py" + +FORBIDDEN_IDENTIFIERS = { + "_gateway_config", + "_load_posts", + "_parser", + "_semantic_hints", + "api_key", + "args", + "base_url", + "conn", + "exc", + "failures", + "limit", + "name", + "normalized", + "post_ids", + "result", + "row", + "rows", + "summary", +} +REQUIRED_IDENTIFIERS = { + "_load_summary_source_posts", + "_orchestrator_gateway_config", + "_post_semantic_hints", + "_post_summary_backfill_parser", + "argument_parser", + "backfill_summary", + "command_arguments", + "database_connection", + "failure_type_counts", + "failure_type_name", + "normalized_post_content", + "orchestrator_api_key", + "orchestrator_base_url", + "post_embedding_client", + "post_failure", + "post_limit", + "post_structure_client", + "post_summary", + "post_summary_client", + "post_vision_client", + "requested_post_ids", + "source_post_record", + "source_post_records", +} + + +def _owned_identifiers(source_tree: ast.AST) -> set[str]: + """Collect package-owned definitions, arguments, and assignment targets.""" + identifiers: set[str] = set() + for syntax_node in ast.walk(source_tree): + if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + identifiers.add(syntax_node.name) + elif isinstance(syntax_node, ast.arg): + identifiers.add(syntax_node.arg) + elif isinstance(syntax_node, ast.Name) and isinstance(syntax_node.ctx, ast.Store): + identifiers.add(syntax_node.id) + return identifiers + + +def test_post_summary_backfill_uses_semantic_private_identifiers() -> None: + """Require semantic names across the complete private operator surface.""" + source_tree = ast.parse(SOURCE_PATH.read_text(encoding="utf-8")) + identifiers = _owned_identifiers(source_tree) + assert not (FORBIDDEN_IDENTIFIERS & identifiers) + assert REQUIRED_IDENTIFIERS <= identifiers + + +def test_post_summary_backfill_preserves_external_contract() -> None: + """Keep existing CLI, JSON, database, and orchestrator boundary contracts.""" + source = SOURCE_PATH.read_text(encoding="utf-8") + required_literals = { + "--target-dsn", + "--post-id", + "--limit", + "--all", + '"requested_posts"', + '"selected_posts"', + '"processed_posts"', + '"project_mentions"', + '"failed_posts"', + '"failure_types"', + "await database_connection.close()", + "asyncpg.connect(target_dsn)", + "PostgresPostSummaryMetadataStore", + "PostgresPostSummaryRepository", + } + assert all(contract_literal in source for contract_literal in required_literals) + + +def test_post_summary_backfill_functions_have_docstrings() -> None: + """Require complete function documentation for the touched operator.""" + source_tree = ast.parse(SOURCE_PATH.read_text(encoding="utf-8")) + function_nodes = [ + syntax_node + for syntax_node in ast.walk(source_tree) + if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + assert function_nodes + assert all(ast.get_docstring(function_node) for function_node in function_nodes) + From 848df025269e1f854bce9aaded1c20389a36cb41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:39:13 +0900 Subject: [PATCH 30/51] refactor(backfill): name post-summary execution context --- CHANGELOG.md | 5 + docs/product-technical-gap-baseline.md | 40 ++- scripts/backfill_post_content.py | 3 + scripts/backfill_post_keymen.py | 4 + scripts/backfill_post_summaries.py | 257 +++++++++++------- scripts/estimate_channel_weights.py | 1 + ...ill_post_summaries_semantic_identifiers.py | 13 +- tests/test_backfill_thread_group_keys.py | 17 ++ ..._weight_estimation_semantic_identifiers.py | 3 + tests/test_estimate_channel_weights_script.py | 10 + ...est_estimate_llm_channel_weights_script.py | 3 + ..._weight_estimation_semantic_identifiers.py | 2 + 12 files changed, 243 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e8e1ecf..1d3d1b6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The post-summary backfill operator now uses semantic parser, orchestrator + gateway, database, source-post, client, content, failure, and aggregate + identifiers while preserving CLI flags, JSON result keys, SQL selection, + transactions, persistence, and connection-close behavior. + - The synchronous post-content backfill now uses semantic command, database, source-post, normalized-content, image-result, aggregate-result, and SQL aliases while preserving CLI flags, JSON result keys, source selection, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c0beb0e7d..e364a1003 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,17 @@ # Product & Technical Gap Baseline +> Post-summary backfill naming overlay: 2026-09-08 KST. Protected `main` is +> `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded operator used +> generic package-owned parser, gateway, database, record, result, client, and +> limit identifiers (`_parser`, `_gateway_config`, `_load_posts`, +> `_semantic_hints`, `conn`, `row`, `result`, `limit`). Action: +> translate its complete private caller surface to post-summary and +> contextual-orchestrator language while preserving CLI flags, JSON result +> keys, SQL selection, transaction, persistence, failure aggregation, and +> connection close. Status: RED naming/docstring/contract regression followed +> by implementation GREEN locally; GitHub exact-head checks and independent +> review remain pending. +> > Synchronous post-content backfill naming overlay: 2026-09-07 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The operator used > generic package-owned command, database, record, normalized-content, image, @@ -10,7 +22,7 @@ > transaction boundaries, and connection close. Status: RED naming/contract > regression followed by production GREEN locally; GitHub exact-head checks and > independent review remain pending. - +> > Post-content queue backfill naming overlay: 2026-09-07 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The private > operator used generic package-owned command, database, queue, record, request, @@ -22,7 +34,7 @@ > and resource close behavior. Status: RED naming/contract regression followed > by production GREEN locally; GitHub exact-head checks and independent review > remain pending. - +> > Occupational catalog synchronizer naming overlay: 2026-09-07 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. The ADR 0250 > operator used generic package-owned command, database, payload, @@ -33,7 +45,7 @@ > transactional UPSERT, and connection close. Status: RED naming/contract > regression followed by production GREEN locally; GitHub exact-head checks > and independent review remain pending. - +> > Deterministic channel-weight estimator naming overlay: 2026-09-07 KST. > Protected `main` is `83eba56149eb802cd63642c507c324c9976ec78e`. > The ADR 0200 operator used generic package-owned sampling, database, @@ -45,7 +57,7 @@ > owned separately by #967; this naming slice adds no source fallback or > dependency change. Status: implementation, behavioral tests, and AST > regression GREEN locally; GitHub exact-head verification pending. - +> > Queued LLM channel-weight estimator overlay: the ADR 0200 batch adapter on > the same protected head used generic owned submit/collect, database, run, > result, score, and command identifiers (`_submit`, `_collect`, `conn`, `run`, @@ -55,7 +67,7 @@ > SQL, transaction boundaries, and fail-closed incomplete judgment behavior. > Status: implementation, behavioral tests, and AST regression GREEN locally; > GitHub exact-head verification pending. - +> > Exact-head naming overlay: 2026-09-07 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded post-Keyman > operator still used generic package-owned command, database, record, and @@ -65,14 +77,14 @@ > persistence contracts, and keep the change Proposed until fresh exact-head > checks and independent review complete. Status: implementation and AST > regression GREEN locally; GitHub verification pending. - +> > Thread-group-key naming overlay: the separate bounded backfill command on the > same exact protected head also used `_run`, `args`, `conn`, `pool`, `row`, and > `rows`. Action: carry the same semantic naming rule through that complete > private caller surface while preserving `--dry-run`, aggregate JSON fields, > SQL, transaction rollback, and persistence behavior. Status: implementation, > behavior tests, and AST regression GREEN locally; GitHub verification pending. - +> > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent @@ -95,7 +107,7 @@ > from plotted coordinates. Do not invent leftover scores. Stack onto > leftover branch `feat/leftover-map-coordinates-v2240`; leave the PR > open for independent review. - +> > Exact-head loop overlay: 2026-08-29 13:15 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent @@ -116,7 +128,7 @@ > only `0` and do not invent drawing-scale `−1` / `+1` ticks. Do not > invent leftover scores. Do not mix into #782; stack onto leftover > branch `feat/leftover-map-coordinates-v2240`. - +> > Exact-head loop overlay: 2026-08-28 19:15 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent @@ -135,7 +147,7 @@ > share omits that axis badge and keeps existing leftover-map axis > text. Do not invent leftover scores. Do not mix into dashboard stacks > #640/#778/#781. - +> > Exact-head loop overlay: 2026-08-28 16:05 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent @@ -149,7 +161,7 @@ > UI-only; no new columns. `R̂` and `d` already are inner product and > length. Do not invent leftover scores. Do not mix into dashboard > stacks #640/#778/#781. - +> > Exact-head loop overlay: 2026-08-28 13:00 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent @@ -160,7 +172,7 @@ > `ξ_{1:2}` / `ζ_{1:2}` (ADR 0267 / migration 0245 / v2.24.0) so > `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖` are buyer-auditable. Do not name > leftover-map inner product, cosine, or length as separate columns. - +> > Exact-head loop overlay: 2026-08-28 10:00 KST. Protected `main` was > `edf22ee39aee2a8481f9bda8fff59801821e79c2` (#773 similar-VOC coverage). > Open ready PRs: #772 (ask_time_axis coverage), #771 (fixtures/vision @@ -176,7 +188,7 @@ > share `e = R̂² / R²` (ADR 0266 / migration 0244 / v2.23.0) so > `e + s + x = 1` is buyer-auditable. Do not persist leftover-map > coordinates in this slice. - +> > Exact-head loop overlay: 2026-08-28 KST. Protected `main` was > `bbb191924e9881a5201f1ecf63c854d92992cc1c`; seven PRs and nine issues were > open. PR #763 was `b51d3bd8872b` and PR #762 was `e6ca33dba1b5`; both were @@ -196,7 +208,7 @@ > evidence, not confirmation of this exact head. The checked repository names > are `ContextualWisdomLab/LineageWeave`, `RankWeave`, `ThreadWeave`, `TEPP`, > and lowercase canonical `ContextualWisdomLab/disksage`. - +> > Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was > `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was diff --git a/scripts/backfill_post_content.py b/scripts/backfill_post_content.py index 69b8b21ba..aec00dee2 100644 --- a/scripts/backfill_post_content.py +++ b/scripts/backfill_post_content.py @@ -41,6 +41,7 @@ def _post_content_backfill_parser() -> argparse.ArgumentParser: + """Build the synchronous post-content backfill argument parser.""" argument_parser = argparse.ArgumentParser(description=__doc__) argument_parser.add_argument( "--target-dsn", @@ -70,6 +71,7 @@ async def backfill_post_content( post_limit: int | None, normalize_only: bool = False, ) -> dict[str, int]: + """Backfill normalized content for selected eligible source posts.""" post_ids = [ str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or []) ] @@ -279,6 +281,7 @@ async def backfill_post_content( def main() -> None: + """Validate command arguments, run the backfill, and print JSON counts.""" command_arguments = _post_content_backfill_parser().parse_args() if command_arguments.limit < 1: raise SystemExit("--limit must be positive") diff --git a/scripts/backfill_post_keymen.py b/scripts/backfill_post_keymen.py index 9c15fe340..0d55d1fe8 100644 --- a/scripts/backfill_post_keymen.py +++ b/scripts/backfill_post_keymen.py @@ -37,6 +37,7 @@ def _first_env(*variable_names: str) -> str: + """Return the first non-empty configured environment value.""" return next( ( os.environ.get(variable_name, "").strip() @@ -48,6 +49,7 @@ def _first_env(*variable_names: str) -> str: def _orchestrator_config() -> tuple[str, str]: + """Resolve the contextual-orchestrator endpoint and credential.""" base_url = _first_env( "ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL" ) @@ -176,6 +178,7 @@ async def _select_posts( async def _run_post_keyman_backfill( command_arguments: argparse.Namespace, ) -> dict[str, object]: + """Run the bounded post-Keyman backfill transaction.""" if command_arguments.post_id and command_arguments.all: raise ValueError("--post-id and --all cannot be combined") base_url, api_key = _orchestrator_config() @@ -254,6 +257,7 @@ async def _run_post_keyman_backfill( def main() -> None: + """Run the post-Keyman operator and print its JSON summary.""" argument_parser = argparse.ArgumentParser(description=__doc__) post_selector = argument_parser.add_mutually_exclusive_group() post_selector.add_argument("--post-id", help="Re-extract one eligible post") diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 2c008fea0..e9f1d8a8e 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -23,38 +23,44 @@ sys.path.insert(0, str(REPOSITORY_ROOT)) from backend.app.post_summary_ingestion import persist_post_summary -from lineageweave.corporate_hierarchy_inference import NullCorporateHierarchyInferenceClient +from lineageweave.corporate_hierarchy_inference import ( + NullCorporateHierarchyInferenceClient, +) from lineageweave.embedding_client import orchestrator_embedding_client from lineageweave.image_content import orchestrator_vision_client from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_content_persistence import persist_post_content -from lineageweave.post_structure import ContextualOrchestratorPostStructureClient, NullPostStructureClient +from lineageweave.post_structure import ( + ContextualOrchestratorPostStructureClient, + NullPostStructureClient, +) from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient from lineageweave.semantic_hints import format_semantic_hints -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( +def _post_summary_backfill_parser() -> argparse.ArgumentParser: + """Build the post-summary backfill command-line parser.""" + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument( "--target-dsn", default=os.environ.get( "DATABASE_URL", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave", ), ) - parser.add_argument("--post-id", action="append", dest="post_ids") - parser.add_argument("--limit", type=int, default=5) - parser.add_argument( + argument_parser.add_argument("--post-id", action="append", dest="post_ids") + argument_parser.add_argument("--limit", type=int, default=5) + argument_parser.add_argument( "--all", action="store_true", help="process every eligible post without an explicit project field", ) - return parser + return argument_parser -def _gateway_config() -> tuple[str, str]: +def _orchestrator_gateway_config() -> tuple[str, str]: """Resolve only the contextual-orchestrator boundary, never its provider.""" return ( os.environ.get("ORCHESTRATOR_BASE_URL", ""), @@ -62,47 +68,55 @@ def _gateway_config() -> tuple[str, str]: ) -def _semantic_hints(row: asyncpg.Record) -> str: - source_author_name = row["source_author_name"] - if source_author_name and source_author_name == row["source_author_code"]: +def _post_semantic_hints(source_post_record: asyncpg.Record) -> str: + """Format semantic hints from one selected source-post record.""" + source_author_name = source_post_record["source_author_name"] + if ( + source_author_name + and source_author_name == source_post_record["source_author_code"] + ): source_author_name = None return format_semantic_hints( - author_name=source_author_name or row["author_name"], - author_affiliations=row["author_affiliations"] or (), - order_pool_code=row["source_sales_pool_code"], - order_pool_name=row["source_sales_pool_name"], - project_field=row["project_field"], - customer_name=row["customer_name"], + author_name=source_author_name or source_post_record["author_name"], + author_affiliations=source_post_record["author_affiliations"] or (), + order_pool_code=source_post_record["source_sales_pool_code"], + order_pool_name=source_post_record["source_sales_pool_name"], + project_field=source_post_record["project_field"], + customer_name=source_post_record["customer_name"], author_account_id=( - str(row["author_account_id"]) if row["author_account_id"] is not None else None + str(source_post_record["author_account_id"]) + if source_post_record["author_account_id"] is not None + else None ), - author_account_name=row["author_name"], - source_author_code=row["source_author_code"], + author_account_name=source_post_record["author_name"], + source_author_code=source_post_record["source_author_code"], source_author_name=source_author_name, - source_company_code=row["source_company_code"], - source_company_name=row["source_company_name"], - source_company_catalog_name=row["source_company_catalog_name"], - source_business_unit_code=row["source_process_unit_code"], - source_process_unit_name=row["source_process_unit_name"], - source_process_unit_catalog_name=row["source_process_unit_catalog_name"], - source_sales_pool_code=row["source_sales_pool_code"], - source_sales_pool_name=row["source_sales_pool_name"], - source_customer_code=row["source_customer_code"], - source_customer_name=row["source_customer_name"], - source_customer_catalog_name=row["source_customer_catalog_name"], - source_project_code=row["source_project_code"], - source_project_name=row["source_project_name"], + source_company_code=source_post_record["source_company_code"], + source_company_name=source_post_record["source_company_name"], + source_company_catalog_name=source_post_record["source_company_catalog_name"], + source_business_unit_code=source_post_record["source_process_unit_code"], + source_process_unit_name=source_post_record["source_process_unit_name"], + source_process_unit_catalog_name=source_post_record[ + "source_process_unit_catalog_name" + ], + source_sales_pool_code=source_post_record["source_sales_pool_code"], + source_sales_pool_name=source_post_record["source_sales_pool_name"], + source_customer_code=source_post_record["source_customer_code"], + source_customer_name=source_post_record["source_customer_name"], + source_customer_catalog_name=source_post_record["source_customer_catalog_name"], + source_project_code=source_post_record["source_project_code"], + source_project_name=source_post_record["source_project_name"], ) -async def _load_posts( - conn: asyncpg.Connection, - post_ids: list[str], - limit: int | None, +async def _load_summary_source_posts( + database_connection: asyncpg.Connection, + requested_post_ids: list[str], + post_limit: int | None, ) -> list[asyncpg.Record]: """Load explicit IDs or one bounded unprojected-post batch.""" return list( - await conn.fetch( + await database_connection.fetch( """ select post.post_id, post.post_title, @@ -202,8 +216,8 @@ async def _load_posts( order by post.created_at, post.post_id limit $2::bigint """, - post_ids or None, - limit, + requested_post_ids or None, + post_limit, ) ) @@ -211,87 +225,138 @@ async def _load_posts( async def backfill_post_summaries( target_dsn: str, raw_post_ids: list[str] | None, - limit: int | None, + post_limit: int | None, ) -> dict[str, object]: - post_ids = [str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or [])] - base_url, api_key = _gateway_config() - if not base_url or not api_key: - raise RuntimeError("contextual-orchestrator gateway credentials are unavailable") + """Backfill summaries for explicit posts or one bounded eligible batch.""" + requested_post_ids = [ + str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or []) + ] + orchestrator_base_url, orchestrator_api_key = _orchestrator_gateway_config() + if not orchestrator_base_url or not orchestrator_api_key: + raise RuntimeError( + "contextual-orchestrator gateway credentials are unavailable" + ) - vision_client = orchestrator_vision_client(base_url, api_key) - if not vision_client.available: - raise RuntimeError("VISION is unavailable; configure contextual-orchestrator before backfill") - summary_client = ContextualOrchestratorPostSummaryClient(base_url, api_key, timeout=180.0) - embedding_client = orchestrator_embedding_client(base_url, api_key) - structure_client = ( - ContextualOrchestratorPostStructureClient(base_url, api_key) - if base_url and api_key + post_vision_client = orchestrator_vision_client( + orchestrator_base_url, + orchestrator_api_key, + ) + if not post_vision_client.available: + raise RuntimeError( + "VISION is unavailable; configure contextual-orchestrator before backfill" + ) + post_summary_client = ContextualOrchestratorPostSummaryClient( + orchestrator_base_url, + orchestrator_api_key, + timeout=180.0, + ) + post_embedding_client = orchestrator_embedding_client( + orchestrator_base_url, + orchestrator_api_key, + ) + post_structure_client = ( + ContextualOrchestratorPostStructureClient( + orchestrator_base_url, + orchestrator_api_key, + ) + if orchestrator_base_url and orchestrator_api_key else NullPostStructureClient() ) - conn = await asyncpg.connect(target_dsn) + database_connection = await asyncpg.connect(target_dsn) try: - rows = await _load_posts(conn, post_ids, limit) - result: dict[str, object] = { - "requested_posts": len(post_ids), - "selected_posts": len(rows), + source_post_records = await _load_summary_source_posts( + database_connection, + requested_post_ids, + post_limit, + ) + backfill_summary: dict[str, object] = { + "requested_posts": len(requested_post_ids), + "selected_posts": len(source_post_records), "processed_posts": 0, "project_mentions": 0, "failed_posts": 0, "failure_types": {}, } - for row in rows: + for source_post_record in source_post_records: try: - with use_llm_metadata(build_post_llm_metadata(str(row["post_id"]), row)): - normalized = normalize_post_body(row["post_body"], vision_client=vision_client) - if not normalized.text.strip(): + with use_llm_metadata( + build_post_llm_metadata( + str(source_post_record["post_id"]), + source_post_record, + ) + ): + normalized_post_content = normalize_post_body( + source_post_record["post_body"], + vision_client=post_vision_client, + ) + if not normalized_post_content.text.strip(): raise ValueError("normalized post body is empty") await persist_post_content( - conn, - str(row["post_id"]), - row["post_body"], - vision_client=vision_client, - embedding_client=embedding_client, - normalized_result=normalized, - structure_client=structure_client, - post_title=row["post_title"], + database_connection, + str(source_post_record["post_id"]), + source_post_record["post_body"], + vision_client=post_vision_client, + embedding_client=post_embedding_client, + normalized_result=normalized_post_content, + structure_client=post_structure_client, + post_title=source_post_record["post_title"], ) - summary = await asyncio.to_thread( - summary_client.summarize_with_hints, - row["post_title"], - normalized.text, - _semantic_hints(row), + post_summary = await asyncio.to_thread( + post_summary_client.summarize_with_hints, + source_post_record["post_title"], + normalized_post_content.text, + _post_semantic_hints(source_post_record), ) await persist_post_summary( - conn, - str(row["post_id"]), - summary, - post_body=normalized.text, + database_connection, + str(source_post_record["post_id"]), + post_summary, + post_body=normalized_post_content.text, hierarchy_inference_client=NullCorporateHierarchyInferenceClient(), verification_client=NullRelationVerificationClient(), ) - result["processed_posts"] = int(result["processed_posts"]) + 1 - result["project_mentions"] = int(result["project_mentions"]) + len(summary.project_mentions) - except Exception as exc: # noqa: BLE001 - one post must not hide other progress. - result["failed_posts"] = int(result["failed_posts"]) + 1 - failures = result["failure_types"] - assert isinstance(failures, dict) - name = type(exc).__name__ - failures[name] = int(failures.get(name, 0)) + 1 - return result + backfill_summary["processed_posts"] = ( + int(backfill_summary["processed_posts"]) + 1 + ) + backfill_summary["project_mentions"] = int( + backfill_summary["project_mentions"] + ) + len(post_summary.project_mentions) + except Exception as post_failure: # noqa: BLE001 - continue the batch. + backfill_summary["failed_posts"] = ( + int(backfill_summary["failed_posts"]) + 1 + ) + failure_type_counts = backfill_summary["failure_types"] + assert isinstance(failure_type_counts, dict) + failure_type_name = type(post_failure).__name__ + failure_type_counts[failure_type_name] = ( + int(failure_type_counts.get(failure_type_name, 0)) + 1 + ) + return backfill_summary finally: - await conn.close() + await database_connection.close() def main() -> None: - args = _parser().parse_args() - if args.limit < 1: + """Validate command arguments, run the backfill, and print JSON counts.""" + command_arguments = _post_summary_backfill_parser().parse_args() + if command_arguments.limit < 1: raise SystemExit("--limit must be positive") - if args.all and args.post_ids: + if command_arguments.all and command_arguments.post_ids: raise SystemExit("--all cannot be combined with --post-id") - limit = None if args.all or args.post_ids else args.limit + post_limit = ( + None + if command_arguments.all or command_arguments.post_ids + else command_arguments.limit + ) print( json.dumps( - asyncio.run(backfill_post_summaries(args.target_dsn, args.post_ids, limit)), + asyncio.run( + backfill_post_summaries( + command_arguments.target_dsn, + command_arguments.post_ids, + post_limit, + ) + ), sort_keys=True, ) ) diff --git a/scripts/estimate_channel_weights.py b/scripts/estimate_channel_weights.py index 317cb2517..fe09635d8 100644 --- a/scripts/estimate_channel_weights.py +++ b/scripts/estimate_channel_weights.py @@ -186,6 +186,7 @@ async def persist_estimate( async def _run_channel_weight_estimation( command_arguments: argparse.Namespace, ) -> dict[str, object]: + """Estimate and persist one channel-weight profile.""" runtime_settings = load_settings() # Short-lived fetch connection; nothing stays open while fitting. database_connection = await asyncpg.connect(runtime_settings.database_url) diff --git a/tests/test_backfill_post_summaries_semantic_identifiers.py b/tests/test_backfill_post_summaries_semantic_identifiers.py index 9434aafcc..2c77fc983 100644 --- a/tests/test_backfill_post_summaries_semantic_identifiers.py +++ b/tests/test_backfill_post_summaries_semantic_identifiers.py @@ -58,11 +58,17 @@ def _owned_identifiers(source_tree: ast.AST) -> set[str]: """Collect package-owned definitions, arguments, and assignment targets.""" identifiers: set[str] = set() for syntax_node in ast.walk(source_tree): - if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if isinstance( + syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): identifiers.add(syntax_node.name) elif isinstance(syntax_node, ast.arg): identifiers.add(syntax_node.arg) - elif isinstance(syntax_node, ast.Name) and isinstance(syntax_node.ctx, ast.Store): + elif isinstance(syntax_node, ast.ExceptHandler) and syntax_node.name: + identifiers.add(syntax_node.name) + elif isinstance(syntax_node, ast.Name) and isinstance( + syntax_node.ctx, ast.Store + ): identifiers.add(syntax_node.id) return identifiers @@ -91,8 +97,6 @@ def test_post_summary_backfill_preserves_external_contract() -> None: '"failure_types"', "await database_connection.close()", "asyncpg.connect(target_dsn)", - "PostgresPostSummaryMetadataStore", - "PostgresPostSummaryRepository", } assert all(contract_literal in source for contract_literal in required_literals) @@ -107,4 +111,3 @@ def test_post_summary_backfill_functions_have_docstrings() -> None: ] assert function_nodes assert all(ast.get_docstring(function_node) for function_node in function_nodes) - diff --git a/tests/test_backfill_thread_group_keys.py b/tests/test_backfill_thread_group_keys.py index e7139363e..252c6e301 100644 --- a/tests/test_backfill_thread_group_keys.py +++ b/tests/test_backfill_thread_group_keys.py @@ -37,15 +37,18 @@ class _Connection: def __init__( self, rows: list[bool], anchored_runs: list[str] | None = None ) -> None: + """Initialize the transaction test double.""" self._rows = rows self._anchored_runs = anchored_runs or [] self.executed: list[str] = [] @asynccontextmanager async def transaction(self): + """Return the transaction test double context.""" yield self async def fetch(self, query: str, *args: object): + """Return deterministic records for the requested query.""" self.executed.append(" ".join(query.split())) if "analysis_run_scope" in query: return [ @@ -58,6 +61,7 @@ async def fetch(self, query: str, *args: object): def test_backfill_clears_placeholders_and_routes_project_codes_to_secondary() -> None: + """Verify placeholders clear and project codes become secondary keys.""" conn = _Connection([True, True, False, False, False]) result = asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=False)) assert result == { @@ -89,6 +93,7 @@ def test_backfill_clears_placeholders_and_routes_project_codes_to_secondary() -> def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned() -> ( None ): + """Reject a backfill that would orphan a scoped analysis run.""" # analysis_scope_thread_group runs resolve `thread_group_key = # scope_key` live on every read (ABAC visibility) -- their member # posts are snapshot-frozen but the scope match is not. Rewriting @@ -106,6 +111,7 @@ def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned( def test_backfill_no_placeholder_rows_is_a_clean_no_op() -> None: + """Treat an empty placeholder selection as a successful no-op.""" conn = _Connection([]) result = asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=False)) assert result == { @@ -115,6 +121,7 @@ def test_backfill_no_placeholder_rows_is_a_clean_no_op() -> None: def test_dry_run_reports_counts_but_raises_to_force_a_rollback() -> None: + """Require dry-run counts while forcing transaction rollback.""" conn = _Connection([True, False]) try: asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=True)) @@ -127,21 +134,28 @@ def test_dry_run_reports_counts_but_raises_to_force_a_rollback() -> None: class _FakePool: def __init__(self, conn: _Connection) -> None: + """Initialize the transaction test double.""" self._conn = conn @asynccontextmanager async def acquire(self): + """Return the configured database connection test double.""" yield self._conn async def close(self) -> None: + """Record closure of the pool test double.""" return None def _patch_pool(monkeypatch, conn: _Connection) -> None: + """Install deterministic pool and settings test doubles.""" + async def fake_create_pool(*_args, **_kwargs): + """Return the configured pool test double.""" return _FakePool(conn) def fake_load_settings(): + """Return deterministic database settings.""" return type("S", (), {"database_url": "postgresql://x"})() monkeypatch.setattr(backfill.asyncpg, "create_pool", fake_create_pool) @@ -151,6 +165,7 @@ def fake_load_settings(): def test_run_reports_dry_run_counts_without_the_internal_exception_leaking( monkeypatch, ) -> None: + """Report dry-run counts without exposing the rollback sentinel.""" import argparse conn = _Connection([True, True, False]) @@ -166,6 +181,7 @@ def test_run_reports_dry_run_counts_without_the_internal_exception_leaking( def test_run_reports_write_counts_when_not_a_dry_run(monkeypatch) -> None: + """Report persisted counts for a write run.""" import argparse conn = _Connection([True, False, False]) @@ -185,6 +201,7 @@ def test_script_entrypoint_reports_dry_run_counts(monkeypatch, capsys) -> None: conn = _Connection([True, False]) async def fake_create_pool(*_args, **_kwargs): + """Return the configured pool test double.""" return _FakePool(conn) script = Path(backfill.__file__) diff --git a/tests/test_channel_weight_estimation_semantic_identifiers.py b/tests/test_channel_weight_estimation_semantic_identifiers.py index 108fdadb8..2f73deee7 100644 --- a/tests/test_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_channel_weight_estimation_semantic_identifiers.py @@ -10,6 +10,7 @@ def _function_identifiers(function_name: str) -> set[str]: + """Collect identifiers owned by the target estimator function.""" source_tree = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8")) function_node = next( node @@ -31,6 +32,7 @@ def _function_identifiers(function_name: str) -> set[str]: def test_owned_estimation_identifiers_are_semantic() -> None: + """Require semantic identifiers throughout deterministic estimation.""" expected_identifiers = { "source_snapshot_digest": { "source_post_rows", @@ -94,6 +96,7 @@ def test_owned_estimation_identifiers_are_semantic() -> None: def test_external_cli_json_and_persistence_contracts_are_unchanged() -> None: + """Preserve deterministic estimator boundary contracts.""" script_source = SCRIPT_PATH.read_text(encoding="utf-8") assert '"--post-limit"' in script_source diff --git a/tests/test_estimate_channel_weights_script.py b/tests/test_estimate_channel_weights_script.py index 19e46ab48..2db4b5074 100644 --- a/tests/test_estimate_channel_weights_script.py +++ b/tests/test_estimate_channel_weights_script.py @@ -23,6 +23,7 @@ def _record(record_id: str, group: str, minute: int, secondary: str = "") -> Record: + """Build one deterministic source-post record fixture.""" return Record( record_id, group, @@ -33,6 +34,7 @@ def _record(record_id: str, group: str, minute: int, secondary: str = "") -> Rec def test_sampling_stays_within_groups_and_window() -> None: + """Keep sampled pairs within group and candidate-window boundaries.""" lineage_records = [ _record("a1", "g-a", 0), _record("a2", "g-a", 1), @@ -51,6 +53,7 @@ def test_sampling_stays_within_groups_and_window() -> None: def test_sampling_window_bounds_candidates_like_reconstruct() -> None: + """Match reconstruction candidate-window bounds.""" lineage_records = [_record(f"r{index}", "g", index) for index in range(5)] _, unbounded_ids, _ = script.sample_pair_scores( lineage_records, candidate_window=50 @@ -62,6 +65,7 @@ def test_sampling_window_bounds_candidates_like_reconstruct() -> None: def test_llm_subsample_stride_is_deterministic_and_spread() -> None: + """Keep LLM subsampling deterministic and distributed.""" # Small totals pass through untouched; larger ones are evenly strided # (first index 0, no index past the end, exactly the limit chosen) # with no randomness, so re-runs stay comparable. @@ -75,6 +79,7 @@ def test_llm_subsample_stride_is_deterministic_and_spread() -> None: def test_snapshot_digest_is_reproducible_and_order_sensitive() -> None: + """Require reproducible order-sensitive source digests.""" source_post_rows = [ {"post_id": "a", "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}, {"post_id": "b", "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc)}, @@ -87,13 +92,16 @@ def test_snapshot_digest_is_reproducible_and_order_sensitive() -> None: class _Connection: def __init__(self) -> None: + """Initialize the estimation database test double.""" self.executed: list[tuple[str, tuple[object, ...]]] = [] @asynccontextmanager async def transaction(self): + """Return the transaction test double context.""" yield self async def execute(self, query: str, *args: object) -> str: + """Record one persistence statement and its arguments.""" self.executed.append((" ".join(query.split()), args)) return "OK" @@ -101,6 +109,7 @@ async def execute(self, query: str, *args: object) -> str: def test_persist_estimate_stamps_full_provenance_on_one_scoped_set( monkeypatch, ) -> None: + """Persist complete provenance for one scoped estimate set.""" monkeypatch.setattr(script, "estimator_version", lambda: "0.9.1") database_connection = _Connection() channel_weight_estimate = ChannelWeightEstimate( @@ -140,6 +149,7 @@ def test_persist_estimate_stamps_full_provenance_on_one_scoped_set( def test_main_rejects_nonpositive_post_limit(monkeypatch) -> None: + """Reject a nonpositive command post limit.""" monkeypatch.setattr( "sys.argv", ["estimate_channel_weights.py", "--post-limit", "0"] ) diff --git a/tests/test_estimate_llm_channel_weights_script.py b/tests/test_estimate_llm_channel_weights_script.py index 44b1bcdb4..2606d446d 100644 --- a/tests/test_estimate_llm_channel_weights_script.py +++ b/tests/test_estimate_llm_channel_weights_script.py @@ -17,6 +17,7 @@ def test_batch_requests_carry_caller_custom_ids_for_every_pair() -> None: + """Attach caller-owned identifiers to every pair request.""" candidate_pair_labels = [("a", "b"), ("c", "d"), ("e", "f")] batch_requests = script.batch_requests_for_pairs([0, 2], candidate_pair_labels) assert [batch_request["custom_id"] for batch_request in batch_requests] == [ @@ -32,6 +33,7 @@ def test_batch_requests_carry_caller_custom_ids_for_every_pair() -> None: def test_shared_judge_prompt_and_confidence_parse_round_trip() -> None: + """Round-trip shared judging prompts and confidence values.""" judgment_prompt = judge_prompt("Record about pricing", "Follow-up record") assert "Record A: Record about pricing" in judgment_prompt assert "Record B: Follow-up record" in judgment_prompt @@ -61,6 +63,7 @@ def test_errored_judgments_stay_unjudged_instead_of_becoming_zero() -> None: def test_batch_completion_is_detected_from_flag_or_status() -> None: + """Recognize batch completion from either supported field.""" assert script._is_complete({"is_complete": True}) assert script._is_complete({"status": "completed"}) assert script._is_complete({"status": "Succeeded"}) diff --git a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py index 9ceb44358..9e01b2fd1 100644 --- a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py @@ -10,6 +10,7 @@ def test_owned_llm_estimation_identifiers_are_semantic() -> None: + """Require semantic identifiers throughout queued LLM estimation.""" script_source = SCRIPT_PATH.read_text(encoding="utf-8") syntax_tree = ast.parse(script_source) owned_identifiers = { @@ -83,6 +84,7 @@ def test_owned_llm_estimation_identifiers_are_semantic() -> None: def test_llm_provider_cli_json_and_persistence_contracts_are_unchanged() -> None: + """Preserve LLM estimator boundary contracts.""" script_source = SCRIPT_PATH.read_text(encoding="utf-8") for contract_literal in ( From a06801aab7713f2638f20a2afaf0a6e35592bb09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:54:42 +0900 Subject: [PATCH 31/51] test(naming): reject generic thread-group test identifiers --- ...group_key_backfill_semantic_identifiers.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_thread_group_key_backfill_semantic_identifiers.py b/tests/test_thread_group_key_backfill_semantic_identifiers.py index 4b2801958..28578d48d 100644 --- a/tests/test_thread_group_key_backfill_semantic_identifiers.py +++ b/tests/test_thread_group_key_backfill_semantic_identifiers.py @@ -7,6 +7,9 @@ SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "backfill_thread_group_keys.py" +BEHAVIOR_TEST_PATH = ( + Path(__file__).parents[1] / "tests" / "test_backfill_thread_group_keys.py" +) def test_thread_group_key_backfill_uses_semantic_identifiers() -> None: @@ -73,3 +76,50 @@ def test_thread_group_key_backfill_preserves_operator_contract() -> None: if isinstance(syntax_node, ast.Call) and isinstance(syntax_node.func, ast.Name) } assert "_run_thread_group_key_backfill" in called_functions + + +def test_thread_group_key_behavior_tests_use_domain_specific_test_doubles() -> None: + """Keep repository-owned test identifiers aligned with the backfill domain.""" + test_source = BEHAVIOR_TEST_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(test_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance( + syntax_node, + (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef), + ) + ) + + assert owned_identifiers.isdisjoint( + { + "_Connection", + "anchored_runs", + "backfill", + "conn", + "exc", + "result", + "rows", + "script", + "update", + } + ) + assert { + "_ThreadGroupDatabaseConnection", + "analysis_run_ids", + "backfill_summary", + "database_connection", + "placeholder_post_rows", + "thread_group_backfill", + "update_query", + } <= owned_identifiers From 0a7205971987c1bb8f998470097947a5bffeed29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:58:52 +0900 Subject: [PATCH 32/51] refactor(tests): name thread-group backfill fixtures --- CHANGELOG.md | 4 + docs/product-technical-gap-baseline.md | 10 ++ tests/test_backfill_thread_group_keys.py | 161 +++++++++++------- ...group_key_backfill_semantic_identifiers.py | 1 - 4 files changed, 112 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3d1b6cf..60da98a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- The thread-group-key backfill test doubles now use semantic database, + placeholder-post, analysis-run, query, and result identifiers while preserving + the operator's behavior and external `asyncpg` protocol method names. + - The post-summary backfill operator now uses semantic parser, orchestrator gateway, database, source-post, client, content, failure, and aggregate identifiers while preserving CLI flags, JSON result keys, SQL selection, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e364a1003..4199a0799 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,15 @@ # Product & Technical Gap Baseline +> Thread-group-key test naming overlay: 2026-09-08 KST. Exact RED head +> `a06801aab7713f2638f20a2afaf0a6e35592bb09` found repository-owned +> `_Connection`, `conn`, `rows`, `result`, `update`, and `script` identifiers in +> the behavioral fixture despite semantic production naming. Action: align the +> complete test-double and local-variable surface with database, placeholder-post, +> analysis-run, update-query, and backfill-summary language while preserving the +> external `asyncpg` `query`/`*args` adapter signature and all behavior. Status: +> RED naming contract reproduced; implementation, focused contract, compile, and +> Ruff GREEN locally; GitHub exact-head checks and independent review pending. +> > Post-summary backfill naming overlay: 2026-09-08 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. The bounded operator used > generic package-owned parser, gateway, database, record, result, client, and diff --git a/tests/test_backfill_thread_group_keys.py b/tests/test_backfill_thread_group_keys.py index 252c6e301..323736358 100644 --- a/tests/test_backfill_thread_group_keys.py +++ b/tests/test_backfill_thread_group_keys.py @@ -16,31 +16,33 @@ from types import SimpleNamespace import asyncpg - import backend.app.config as backend_config -import scripts.backfill_thread_group_keys as backfill + +import scripts.backfill_thread_group_keys as thread_group_backfill -class _Connection: +class _ThreadGroupDatabaseConnection: """Simulates the guard SELECT and the UPDATE...RETURNING this script issues. - ``rows`` models every ``source_post`` row carrying the placeholder + ``placeholder_post_rows`` models every ``source_post`` row carrying the placeholder signature (``thread_group_key`` equal to the row's own record key): each entry is ``had_project_code`` (True when the row's ``source_project_code`` was non-empty, so it now feeds the - secondary-key evidence channel). A row NOT in ``rows`` models a + secondary-key evidence channel). A row NOT in ``placeholder_post_rows`` models a seeded/genuinely-mapped row the placeholder predicate never touches. - ``anchored_runs`` models existing analysis_scope_thread_group runs + ``analysis_run_ids`` models existing analysis_scope_thread_group runs whose live scope match the rewrite would orphan. """ def __init__( - self, rows: list[bool], anchored_runs: list[str] | None = None + self, + placeholder_post_rows: list[bool], + analysis_run_ids: list[str] | None = None, ) -> None: """Initialize the transaction test double.""" - self._rows = rows - self._anchored_runs = anchored_runs or [] - self.executed: list[str] = [] + self._placeholder_post_rows = placeholder_post_rows + self._analysis_run_ids = analysis_run_ids or [] + self.executed_queries: list[str] = [] @asynccontextmanager async def transaction(self): @@ -49,45 +51,55 @@ async def transaction(self): async def fetch(self, query: str, *args: object): """Return deterministic records for the requested query.""" - self.executed.append(" ".join(query.split())) + self.executed_queries.append(" ".join(query.split())) if "analysis_run_scope" in query: return [ - {"analysis_run_id": run_id, "scope_key": f"key-{run_id}"} - for run_id in self._anchored_runs + { + "analysis_run_id": analysis_run_id, + "scope_key": f"key-{analysis_run_id}", + } + for analysis_run_id in self._analysis_run_ids ] return [ - {"had_project_code": had_project_code} for had_project_code in self._rows + {"had_project_code": had_project_code} + for had_project_code in self._placeholder_post_rows ] def test_backfill_clears_placeholders_and_routes_project_codes_to_secondary() -> None: """Verify placeholders clear and project codes become secondary keys.""" - conn = _Connection([True, True, False, False, False]) - result = asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=False)) - assert result == { + database_connection = _ThreadGroupDatabaseConnection( + [True, True, False, False, False] + ) + backfill_summary = asyncio.run( + thread_group_backfill.backfill_thread_group_keys( + database_connection, dry_run=False + ) + ) + assert backfill_summary == { "cleared_placeholder_posts": 5, "project_secondary_evidence_posts": 2, } - assert len(conn.executed) == 2 - assert "analysis_run_scope" in conn.executed[0] - update = conn.executed[1] - assert "update source_post" in update + assert len(database_connection.executed_queries) == 2 + assert "analysis_run_scope" in database_connection.executed_queries[0] + update_query = database_connection.executed_queries[1] + assert "update source_post" in update_query # The placeholder signature is the only predicate -- seeded rows with # real designed keys must never match. - assert "btrim(thread_group_key) = btrim(source_record_key)" in update + assert "btrim(thread_group_key) = btrim(source_record_key)" in update_query # Project code is routed to the secondary-key evidence channel, never # to thread_group_key -- a hard project partition would wall off # related posts that lack a project code, exactly the links the # reconstruction library exists to find. - assert "thread_group_key = ''" in update + assert "thread_group_key = ''" in update_query assert ( "secondary_grouping_key = coalesce(nullif(btrim(source_project_code), ''), '')" - in update + in update_query ) - assert "source_thread_group_key = coalesce(" in update - assert "source_thread_group_key, thread_group_key" in update - assert "source_secondary_grouping_key = coalesce(" in update - assert "source_secondary_grouping_key, secondary_grouping_key" in update + assert "source_thread_group_key = coalesce(" in update_query + assert "source_thread_group_key, thread_group_key" in update_query + assert "source_secondary_grouping_key = coalesce(" in update_query + assert "source_secondary_grouping_key, secondary_grouping_key" in update_query def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned() -> ( @@ -99,22 +111,35 @@ def test_backfill_fails_closed_when_a_thread_group_scoped_run_would_be_orphaned( # posts are snapshot-frozen but the scope match is not. Rewriting # the keys out from under such a run silently detaches it, so the # backfill must refuse instead, before any UPDATE. - conn = _Connection([True, False], anchored_runs=["run-1", "run-2"]) + database_connection = _ThreadGroupDatabaseConnection( + [True, False], analysis_run_ids=["run-1", "run-2"] + ) try: - asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=False)) - except RuntimeError as exc: - assert "run-1" in str(exc) - assert "run-2" in str(exc) + asyncio.run( + thread_group_backfill.backfill_thread_group_keys( + database_connection, dry_run=False + ) + ) + except RuntimeError as runtime_error: + assert "run-1" in str(runtime_error) + assert "run-2" in str(runtime_error) else: raise AssertionError("expected RuntimeError") - assert all("update source_post" not in query for query in conn.executed) + assert all( + "update source_post" not in query + for query in database_connection.executed_queries + ) def test_backfill_no_placeholder_rows_is_a_clean_no_op() -> None: """Treat an empty placeholder selection as a successful no-op.""" - conn = _Connection([]) - result = asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=False)) - assert result == { + database_connection = _ThreadGroupDatabaseConnection([]) + backfill_summary = asyncio.run( + thread_group_backfill.backfill_thread_group_keys( + database_connection, dry_run=False + ) + ) + assert backfill_summary == { "cleared_placeholder_posts": 0, "project_secondary_evidence_posts": 0, } @@ -122,10 +147,14 @@ def test_backfill_no_placeholder_rows_is_a_clean_no_op() -> None: def test_dry_run_reports_counts_but_raises_to_force_a_rollback() -> None: """Require dry-run counts while forcing transaction rollback.""" - conn = _Connection([True, False]) + database_connection = _ThreadGroupDatabaseConnection([True, False]) try: - asyncio.run(backfill.backfill_thread_group_keys(conn, dry_run=True)) - except backfill._RollbackDryRun as rolled_back: + asyncio.run( + thread_group_backfill.backfill_thread_group_keys( + database_connection, dry_run=True + ) + ) + except thread_group_backfill._RollbackDryRun as rolled_back: assert rolled_back.project_evidence_post_count == 1 assert rolled_back.cleared_post_count == 2 else: @@ -133,33 +162,35 @@ def test_dry_run_reports_counts_but_raises_to_force_a_rollback() -> None: class _FakePool: - def __init__(self, conn: _Connection) -> None: + def __init__(self, database_connection: _ThreadGroupDatabaseConnection) -> None: """Initialize the transaction test double.""" - self._conn = conn + self._database_connection = database_connection @asynccontextmanager async def acquire(self): """Return the configured database connection test double.""" - yield self._conn + yield self._database_connection async def close(self) -> None: """Record closure of the pool test double.""" - return None + return -def _patch_pool(monkeypatch, conn: _Connection) -> None: +def _patch_database_pool( + monkeypatch, database_connection: _ThreadGroupDatabaseConnection +) -> None: """Install deterministic pool and settings test doubles.""" async def fake_create_pool(*_args, **_kwargs): """Return the configured pool test double.""" - return _FakePool(conn) + return _FakePool(database_connection) def fake_load_settings(): """Return deterministic database settings.""" return type("S", (), {"database_url": "postgresql://x"})() - monkeypatch.setattr(backfill.asyncpg, "create_pool", fake_create_pool) - monkeypatch.setattr(backfill, "load_settings", fake_load_settings) + monkeypatch.setattr(thread_group_backfill.asyncpg, "create_pool", fake_create_pool) + monkeypatch.setattr(thread_group_backfill, "load_settings", fake_load_settings) def test_run_reports_dry_run_counts_without_the_internal_exception_leaking( @@ -168,12 +199,14 @@ def test_run_reports_dry_run_counts_without_the_internal_exception_leaking( """Report dry-run counts without exposing the rollback sentinel.""" import argparse - conn = _Connection([True, True, False]) - _patch_pool(monkeypatch, conn) - result = asyncio.run( - backfill._run_thread_group_key_backfill(argparse.Namespace(dry_run=True)) + database_connection = _ThreadGroupDatabaseConnection([True, True, False]) + _patch_database_pool(monkeypatch, database_connection) + backfill_summary = asyncio.run( + thread_group_backfill._run_thread_group_key_backfill( + argparse.Namespace(dry_run=True) + ) ) - assert result == { + assert backfill_summary == { "cleared_placeholder_posts": 3, "project_secondary_evidence_posts": 2, "dry_run": True, @@ -184,12 +217,14 @@ def test_run_reports_write_counts_when_not_a_dry_run(monkeypatch) -> None: """Report persisted counts for a write run.""" import argparse - conn = _Connection([True, False, False]) - _patch_pool(monkeypatch, conn) - result = asyncio.run( - backfill._run_thread_group_key_backfill(argparse.Namespace(dry_run=False)) + database_connection = _ThreadGroupDatabaseConnection([True, False, False]) + _patch_database_pool(monkeypatch, database_connection) + backfill_summary = asyncio.run( + thread_group_backfill._run_thread_group_key_backfill( + argparse.Namespace(dry_run=False) + ) ) - assert result == { + assert backfill_summary == { "cleared_placeholder_posts": 3, "project_secondary_evidence_posts": 1, "dry_run": False, @@ -198,22 +233,22 @@ def test_run_reports_write_counts_when_not_a_dry_run(monkeypatch) -> None: def test_script_entrypoint_reports_dry_run_counts(monkeypatch, capsys) -> None: """The documented operator command executes the rollback-safe boundary.""" - conn = _Connection([True, False]) + database_connection = _ThreadGroupDatabaseConnection([True, False]) async def fake_create_pool(*_args, **_kwargs): """Return the configured pool test double.""" - return _FakePool(conn) + return _FakePool(database_connection) - script = Path(backfill.__file__) + script_path = Path(thread_group_backfill.__file__) monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) monkeypatch.setattr( backend_config, "load_settings", lambda: SimpleNamespace(database_url="postgresql://synthetic"), ) - monkeypatch.setattr(sys, "argv", [str(script), "--dry-run"]) + monkeypatch.setattr(sys, "argv", [str(script_path), "--dry-run"]) - runpy.run_path(str(script), run_name="__main__") + runpy.run_path(str(script_path), run_name="__main__") assert capsys.readouterr().out == ( '{"cleared_placeholder_posts": 2, "dry_run": true, ' diff --git a/tests/test_thread_group_key_backfill_semantic_identifiers.py b/tests/test_thread_group_key_backfill_semantic_identifiers.py index 28578d48d..648a95cd7 100644 --- a/tests/test_thread_group_key_backfill_semantic_identifiers.py +++ b/tests/test_thread_group_key_backfill_semantic_identifiers.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "backfill_thread_group_keys.py" BEHAVIOR_TEST_PATH = ( Path(__file__).parents[1] / "tests" / "test_backfill_thread_group_keys.py" From 3620f8b9f4775a3511c4bb8f56e6c9882af702dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:01:31 +0900 Subject: [PATCH 33/51] test(naming): reject generic estimator test identifiers --- ..._weight_estimation_semantic_identifiers.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_channel_weight_estimation_semantic_identifiers.py b/tests/test_channel_weight_estimation_semantic_identifiers.py index 2f73deee7..4f9629227 100644 --- a/tests/test_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_channel_weight_estimation_semantic_identifiers.py @@ -7,6 +7,7 @@ SCRIPT_PATH = Path("scripts/estimate_channel_weights.py") +BEHAVIOR_TEST_PATH = Path("tests/test_estimate_channel_weights_script.py") def _function_identifiers(function_name: str) -> set[str]: @@ -123,3 +124,54 @@ def test_external_cli_json_and_persistence_contracts_are_unchanged() -> None: "asyncio.run(_run_channel_weight_estimation(command_arguments))" in script_source ) + + +def test_estimator_behavior_tests_use_domain_specific_fixture_names() -> None: + """Keep owned deterministic-estimator test identifiers semantic.""" + test_source = BEHAVIOR_TEST_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(test_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance( + syntax_node, + (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef), + ) + ) + + assert owned_identifiers.isdisjoint( + { + "_Connection", + "_record", + "chosen", + "executed", + "first", + "group", + "inserted", + "minute", + "script", + "secondary", + } + ) + assert { + "_EstimationDatabaseConnection", + "_source_post_record", + "channel_weight_script", + "chosen_indexes", + "executed_queries", + "first_digest", + "inserted_rows", + "minute_offset", + "secondary_grouping_key", + "thread_group_key", + } <= owned_identifiers From da591c5b522a82dc4039fa9f67e94bc37698fcc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:04:27 +0900 Subject: [PATCH 34/51] refactor(tests): name estimator fixtures and evidence --- CHANGELOG.md | 4 + docs/product-technical-gap-baseline.md | 11 +++ ..._weight_estimation_semantic_identifiers.py | 6 +- tests/test_estimate_channel_weights_script.py | 99 +++++++++++-------- 4 files changed, 77 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60da98a75..5e4945799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- The deterministic channel-weight estimator tests now use semantic source-post, + thread-group, sampling-index, digest, database, query, and persistence names + while preserving estimator behavior and external database protocol signatures. + - The thread-group-key backfill test doubles now use semantic database, placeholder-post, analysis-run, query, and result identifiers while preserving the operator's behavior and external `asyncpg` protocol method names. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4199a0799..98dc609d4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,16 @@ # Product & Technical Gap Baseline +> Deterministic estimator test naming overlay: 2026-09-08 KST. Exact RED head +> `3620f8b9f4775a3511c4bb8f56e6c9882af702dd` found repository-owned +> `_record`, `_Connection`, `script`, `chosen`, `first`, `inserted`, and +> `executed` identifiers in the behavioral fixture. Action: align the fixture +> with source-post, thread-group, sampling-index, snapshot-digest, database, +> query, and persisted-row language while preserving production estimator calls, +> SQL/provenance assertions, and external `execute(query, *args)`. Status: RED +> reproduced; AST contracts, compile, and Ruff/format GREEN locally with the +> pre-existing naive-datetime lint excluded from this naming-only slice; GitHub +> exact-head checks and independent review pending. +> > Thread-group-key test naming overlay: 2026-09-08 KST. Exact RED head > `a06801aab7713f2638f20a2afaf0a6e35592bb09` found repository-owned > `_Connection`, `conn`, `rows`, `result`, `update`, and `script` identifiers in diff --git a/tests/test_channel_weight_estimation_semantic_identifiers.py b/tests/test_channel_weight_estimation_semantic_identifiers.py index 4f9629227..793382da5 100644 --- a/tests/test_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_channel_weight_estimation_semantic_identifiers.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - SCRIPT_PATH = Path("scripts/estimate_channel_weights.py") BEHAVIOR_TEST_PATH = Path("tests/test_estimate_channel_weights_script.py") @@ -148,6 +147,11 @@ def test_estimator_behavior_tests_use_domain_specific_fixture_names() -> None: (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef), ) ) + owned_identifiers.update( + syntax_node.attr + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Attribute) + ) assert owned_identifiers.isdisjoint( { diff --git a/tests/test_estimate_channel_weights_script.py b/tests/test_estimate_channel_weights_script.py index 2db4b5074..8c4e328e1 100644 --- a/tests/test_estimate_channel_weights_script.py +++ b/tests/test_estimate_channel_weights_script.py @@ -15,32 +15,36 @@ from datetime import datetime, timedelta, timezone import pytest - from lineageweave.channel_weight_estimation import ChannelWeightEstimate from lineageweave.models import Record -import scripts.estimate_channel_weights as script +import scripts.estimate_channel_weights as channel_weight_script -def _record(record_id: str, group: str, minute: int, secondary: str = "") -> Record: +def _source_post_record( + source_record_id: str, + thread_group_key: str, + minute_offset: int, + secondary_grouping_key: str = "", +) -> Record: """Build one deterministic source-post record fixture.""" return Record( - record_id, - group, - f"title {record_id}", - datetime(2026, 1, 1) + timedelta(minutes=minute), - secondary, + source_record_id, + thread_group_key, + f"title {source_record_id}", + datetime(2026, 1, 1) + timedelta(minutes=minute_offset), + secondary_grouping_key, ) def test_sampling_stays_within_groups_and_window() -> None: """Keep sampled pairs within group and candidate-window boundaries.""" lineage_records = [ - _record("a1", "g-a", 0), - _record("a2", "g-a", 1), - _record("b1", "g-b", 2), + _source_post_record("a1", "g-a", 0), + _source_post_record("a2", "g-a", 1), + _source_post_record("b1", "g-b", 2), ] - pair_scores, group_ids, pair_labels = script.sample_pair_scores( + pair_scores, group_ids, pair_labels = channel_weight_script.sample_pair_scores( lineage_records, candidate_window=50 ) # Only a1->a2 pairs up; b1 is alone in its group and never crosses. @@ -54,12 +58,16 @@ def test_sampling_stays_within_groups_and_window() -> None: def test_sampling_window_bounds_candidates_like_reconstruct() -> None: """Match reconstruction candidate-window bounds.""" - lineage_records = [_record(f"r{index}", "g", index) for index in range(5)] - _, unbounded_ids, _ = script.sample_pair_scores( + lineage_records = [ + _source_post_record(f"r{index}", "g", index) for index in range(5) + ] + _, unbounded_ids, _ = channel_weight_script.sample_pair_scores( lineage_records, candidate_window=50 ) assert len(unbounded_ids) == 4 + 3 + 2 + 1 - pair_scores, _, _ = script.sample_pair_scores(lineage_records, candidate_window=2) + pair_scores, _, _ = channel_weight_script.sample_pair_scores( + lineage_records, candidate_window=2 + ) # Each record sees at most its two immediate predecessors. assert len(pair_scores) == 1 + 2 + 2 + 2 @@ -69,13 +77,13 @@ def test_llm_subsample_stride_is_deterministic_and_spread() -> None: # Small totals pass through untouched; larger ones are evenly strided # (first index 0, no index past the end, exactly the limit chosen) # with no randomness, so re-runs stay comparable. - assert script.subsample_stride(3, 10) == [0, 1, 2] - chosen = script.subsample_stride(1000, 40) - assert len(chosen) == 40 - assert chosen[0] == 0 - assert chosen == sorted(chosen) - assert chosen[-1] <= 999 - assert script.subsample_stride(1000, 40) == chosen + assert channel_weight_script.subsample_stride(3, 10) == [0, 1, 2] + chosen_indexes = channel_weight_script.subsample_stride(1000, 40) + assert len(chosen_indexes) == 40 + assert chosen_indexes[0] == 0 + assert chosen_indexes == sorted(chosen_indexes) + assert chosen_indexes[-1] <= 999 + assert channel_weight_script.subsample_stride(1000, 40) == chosen_indexes def test_snapshot_digest_is_reproducible_and_order_sensitive() -> None: @@ -84,16 +92,20 @@ def test_snapshot_digest_is_reproducible_and_order_sensitive() -> None: {"post_id": "a", "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}, {"post_id": "b", "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc)}, ] - first = script.source_snapshot_digest(source_post_rows) - assert first == script.source_snapshot_digest(list(source_post_rows)) - assert first != script.source_snapshot_digest(list(reversed(source_post_rows))) - assert len(first) == 64 + first_digest = channel_weight_script.source_snapshot_digest(source_post_rows) + assert first_digest == channel_weight_script.source_snapshot_digest( + list(source_post_rows) + ) + assert first_digest != channel_weight_script.source_snapshot_digest( + list(reversed(source_post_rows)) + ) + assert len(first_digest) == 64 -class _Connection: +class _EstimationDatabaseConnection: def __init__(self) -> None: """Initialize the estimation database test double.""" - self.executed: list[tuple[str, tuple[object, ...]]] = [] + self.executed_queries: list[tuple[str, tuple[object, ...]]] = [] @asynccontextmanager async def transaction(self): @@ -102,7 +114,7 @@ async def transaction(self): async def execute(self, query: str, *args: object) -> str: """Record one persistence statement and its arguments.""" - self.executed.append((" ".join(query.split()), args)) + self.executed_queries.append((" ".join(query.split()), args)) return "OK" @@ -110,8 +122,8 @@ def test_persist_estimate_stamps_full_provenance_on_one_scoped_set( monkeypatch, ) -> None: """Persist complete provenance for one scoped estimate set.""" - monkeypatch.setattr(script, "estimator_version", lambda: "0.9.1") - database_connection = _Connection() + monkeypatch.setattr(channel_weight_script, "estimator_version", lambda: "0.9.1") + database_connection = _EstimationDatabaseConnection() channel_weight_estimate = ChannelWeightEstimate( weights={"temporal": 0.25, "text": 0.75}, sample_pair_count=600, @@ -119,33 +131,36 @@ def test_persist_estimate_stamps_full_provenance_on_one_scoped_set( ) knowledge_cutoff = datetime(2026, 1, 2, tzinfo=timezone.utc) estimation_run_id = asyncio.run( - script.persist_estimate( + channel_weight_script.persist_estimate( database_connection, channel_weight_estimate, - channel_set_code=script.DETERMINISTIC_SET_CODE, + channel_set_code=channel_weight_script.DETERMINISTIC_SET_CODE, snapshot_sha256="a" * 64, knowledge_cutoff=knowledge_cutoff, ) ) - delete_query, delete_args = database_connection.executed[0] + delete_query, delete_args = database_connection.executed_queries[0] # Scoped delete: persisting the deterministic set must never wipe # another set -- each active-channel combination owns its own rows. assert ( "delete from lineage_channel_weight where channel_set_code = $1" in delete_query ) - assert delete_args == (script.DETERMINISTIC_SET_CODE,) - inserted = {call[1][1]: call[1] for call in database_connection.executed[1:]} - assert set(inserted) == {"temporal", "text"} - for persisted_row in inserted.values(): - assert persisted_row[0] == script.DETERMINISTIC_SET_CODE + assert delete_args == (channel_weight_script.DETERMINISTIC_SET_CODE,) + inserted_rows = { + query_call[1][1]: query_call[1] + for query_call in database_connection.executed_queries[1:] + } + assert set(inserted_rows) == {"temporal", "text"} + for persisted_row in inserted_rows.values(): + assert persisted_row[0] == channel_weight_script.DETERMINISTIC_SET_CODE assert persisted_row[3] == estimation_run_id assert persisted_row[4] == "mls2plm_expected_information" assert isinstance(persisted_row[5], str) and persisted_row[5].strip() - assert persisted_row[6] == script.UNANCHORED_METHOD_CODE + assert persisted_row[6] == channel_weight_script.UNANCHORED_METHOD_CODE assert persisted_row[7] == "a" * 64 assert persisted_row[8] == 600 assert persisted_row[9] == knowledge_cutoff - assert inserted["text"][2] == 0.75 + assert inserted_rows["text"][2] == 0.75 def test_main_rejects_nonpositive_post_limit(monkeypatch) -> None: @@ -154,4 +169,4 @@ def test_main_rejects_nonpositive_post_limit(monkeypatch) -> None: "sys.argv", ["estimate_channel_weights.py", "--post-limit", "0"] ) with pytest.raises(SystemExit): - script.main() + channel_weight_script.main() From e73e0a1afd66432022c81ef86e34fd15f20ee162 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:07:00 +0900 Subject: [PATCH 35/51] test(naming): reject generic queued-estimator alias --- ...el_weight_estimation_semantic_identifiers.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py index 9e01b2fd1..fc1a3a5f6 100644 --- a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py @@ -7,6 +7,7 @@ SCRIPT_PATH = Path("scripts/estimate_llm_channel_weights.py") +BEHAVIOR_TEST_PATH = Path("tests/test_estimate_llm_channel_weights_script.py") def test_owned_llm_estimation_identifiers_are_semantic() -> None: @@ -111,3 +112,19 @@ def test_llm_provider_cli_json_and_persistence_contracts_are_unchanged() -> None assert contract_literal in script_source assert "asyncio.run(_submit_batch_estimation(command_arguments))" in script_source assert "asyncio.run(_collect_batch_estimation(command_arguments))" in script_source + + +def test_llm_estimator_behavior_tests_name_the_script_boundary() -> None: + """Reject a generic alias for the queued-estimator module under test.""" + test_source = BEHAVIOR_TEST_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(test_source) + owned_aliases = { + imported_name.asname + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Import) + for imported_name in syntax_node.names + if imported_name.asname + } + + assert "script" not in owned_aliases + assert "llm_estimation_script" in owned_aliases From 26f9ffd682467cce4888e67d251672cd89fd2c1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:08:47 +0900 Subject: [PATCH 36/51] refactor(tests): name queued-estimator module boundary --- CHANGELOG.md | 3 +++ docs/product-technical-gap-baseline.md | 9 +++++++++ ...est_estimate_llm_channel_weights_script.py | 19 ++++++++++--------- ..._weight_estimation_semantic_identifiers.py | 1 - 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4945799..ffb1fe12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,9 @@ All notable changes to this project are documented here. Format follows ### Changed +- The queued LLM channel-weight estimator behavior tests now name their module + boundary explicitly instead of using the generic `script` alias. + - The deterministic channel-weight estimator tests now use semantic source-post, thread-group, sampling-index, digest, database, query, and persistence names while preserving estimator behavior and external database protocol signatures. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 98dc609d4..f5a2e2b1b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,14 @@ # Product & Technical Gap Baseline +> Queued estimator test naming overlay: 2026-09-08 KST. Exact RED head +> `e73e0a1afd66432022c81ef86e34fd15f20ee162` found the generic owned +> `script` alias in the ADR 0200 queued-estimator behavior tests. Action: name +> the module boundary `llm_estimation_script` throughout its complete caller +> surface while preserving provider fixtures, caller `custom_id` mapping, +> confidence parsing, and completion semantics. Status: RED reproduced; AST +> contracts, compile, and Ruff/format GREEN locally; GitHub exact-head checks +> and independent review pending. +> > Deterministic estimator test naming overlay: 2026-09-08 KST. Exact RED head > `3620f8b9f4775a3511c4bb8f56e6c9882af702dd` found repository-owned > `_record`, `_Connection`, `script`, `chosen`, `first`, `inserted`, and diff --git a/tests/test_estimate_llm_channel_weights_script.py b/tests/test_estimate_llm_channel_weights_script.py index 2606d446d..33316095c 100644 --- a/tests/test_estimate_llm_channel_weights_script.py +++ b/tests/test_estimate_llm_channel_weights_script.py @@ -9,17 +9,18 @@ from __future__ import annotations import pytest - from lineageweave.adjudication_client import judge_prompt, parse_confidence from lineageweave.http_client import HttpClientError -import scripts.estimate_llm_channel_weights as script +import scripts.estimate_llm_channel_weights as llm_estimation_script def test_batch_requests_carry_caller_custom_ids_for_every_pair() -> None: """Attach caller-owned identifiers to every pair request.""" candidate_pair_labels = [("a", "b"), ("c", "d"), ("e", "f")] - batch_requests = script.batch_requests_for_pairs([0, 2], candidate_pair_labels) + batch_requests = llm_estimation_script.batch_requests_for_pairs( + [0, 2], candidate_pair_labels + ) assert [batch_request["custom_id"] for batch_request in batch_requests] == [ "pair-0", "pair-2", @@ -49,7 +50,7 @@ def test_errored_judgments_stay_unjudged_instead_of_becoming_zero() -> None: 0.0 -- the pair stays unjudged and the incomplete-run path reports it. Mapping is by custom_id only; foreign or malformed ids are ignored. """ - judgment_updates = script.judgment_updates_from_results( + judgment_updates = llm_estimation_script.judgment_updates_from_results( [ {"custom_id": "pair-3", "answer": "0.7"}, {"custom_id": "pair-4", "answer": ""}, @@ -64,8 +65,8 @@ def test_errored_judgments_stay_unjudged_instead_of_becoming_zero() -> None: def test_batch_completion_is_detected_from_flag_or_status() -> None: """Recognize batch completion from either supported field.""" - assert script._is_complete({"is_complete": True}) - assert script._is_complete({"status": "completed"}) - assert script._is_complete({"status": "Succeeded"}) - assert not script._is_complete({"status": "in_progress"}) - assert not script._is_complete({}) + assert llm_estimation_script._is_complete({"is_complete": True}) + assert llm_estimation_script._is_complete({"status": "completed"}) + assert llm_estimation_script._is_complete({"status": "Succeeded"}) + assert not llm_estimation_script._is_complete({"status": "in_progress"}) + assert not llm_estimation_script._is_complete({}) diff --git a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py index fc1a3a5f6..d9c99648e 100644 --- a/tests/test_llm_channel_weight_estimation_semantic_identifiers.py +++ b/tests/test_llm_channel_weight_estimation_semantic_identifiers.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - SCRIPT_PATH = Path("scripts/estimate_llm_channel_weights.py") BEHAVIOR_TEST_PATH = Path("tests/test_estimate_llm_channel_weights_script.py") From fbde01a4696362d659db2f4b33f588882bdb8385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:25:35 +0900 Subject: [PATCH 37/51] test(ontology): require semantic site-builder identifiers --- ...uild_ontology_site_semantic_identifiers.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/test_build_ontology_site_semantic_identifiers.py diff --git a/tests/test_build_ontology_site_semantic_identifiers.py b/tests/test_build_ontology_site_semantic_identifiers.py new file mode 100644 index 000000000..413bc4526 --- /dev/null +++ b/tests/test_build_ontology_site_semantic_identifiers.py @@ -0,0 +1,67 @@ +"""Naming contract for the deterministic ontology-site builder.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "build_ontology_site.py" + + +def test_ontology_site_builder_uses_semantic_owned_identifiers() -> None: + """Keep ontology, serialization, manifest, and CLI names specific.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + + assert owned_identifiers.isdisjoint( + { + "args", + "item", + "key", + "parser", + "payload", + "rows", + "value", + } + ) + assert { + "command_arguments", + "json_item", + "json_key", + "json_value", + "literal_value", + "manifest_payload", + "ontology_resource", + "relation_rows", + "site_build_parser", + } <= owned_identifiers + + +def test_ontology_site_builder_preserves_publication_contracts() -> None: + """Keep public URLs, CLI flags, formats, and manifest keys stable.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"--repository-root"', + '"--output-dir"', + '"documentation_url"', + '"generated_artifacts"', + '"ontology_triple_count"', + '"ontology_unique_term_count"', + '"source_sha256"', + 'format="json-ld"', + 'format="nt"', + 'format="turtle"', + ): + assert contract_literal in script_source From f749fb2714fef5975ba042f9356c10a120f671ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 12:32:31 +0900 Subject: [PATCH 38/51] refactor(ontology): name site-builder identifiers --- CHANGELOG.md | 4 + docs/product-technical-gap-baseline.md | 12 ++ scripts/build_ontology_site.py | 120 +++++++++++------- ...uild_ontology_site_semantic_identifiers.py | 1 - 4 files changed, 91 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb1fe12d..d3164837f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- The deterministic ontology-site builder now uses semantic ontology-resource, + JSON-LD item, relation-row, manifest-payload, and CLI identifiers while + preserving public URLs, formats, manifest fields, generated bytes, and flags. + - The queued LLM channel-weight estimator behavior tests now name their module boundary explicitly instead of using the generic `script` alias. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f5a2e2b1b..ecfb0a564 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,17 @@ # Product & Technical Gap Baseline +> Ontology-site builder naming overlay: 2026-09-08 KST. Protected `main` is +> `83eba56149eb802cd63642c507c324c9976ec78e`; exact RED head +> `fbde01a4696362d659db2f4b33f588882bdb8385` found repository-owned +> `value`, `key`, `item`, `rows`, `payload`, `parser`, and `args` identifiers +> across RDF rendering, JSON-LD canonicalization, manifest output, and the CLI. +> Action: align that complete builder surface with ontology-resource, +> serialization-item, relation-row, manifest-payload, and command language while +> preserving published URLs, CLI flags, RDF formats, manifest keys, generated +> bytes, and deterministic ordering. Status: RED reproduced; implementation, +> focused publication behavior, AST contract, compile, and scoped lint GREEN +> locally; GitHub exact-head checks and independent review pending. +> > Queued estimator test naming overlay: 2026-09-08 KST. Exact RED head > `e73e0a1afd66432022c81ef86e34fd15f20ee162` found the generic owned > `script` alias in the ADR 0200 queued-estimator behavior tests. Action: name diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 497b3757c..08f5266ae 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -72,45 +72,58 @@ def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() -def _fragment(value: URIRef) -> str: +def _fragment(ontology_resource: URIRef) -> str: """Return the stable local fragment used as the HTML anchor.""" - iri = str(value) - if "#" in iri: - return iri.rsplit("#", 1)[1] - return iri.rstrip("/").rsplit("/", 1)[-1] + ontology_iri = str(ontology_resource) + if "#" in ontology_iri: + return ontology_iri.rsplit("#", 1)[1] + return ontology_iri.rstrip("/").rsplit("/", 1)[-1] def _preferred_literal(graph: Graph, subject: URIRef, predicate: URIRef) -> str | None: """Choose an English, untagged, or first literal in a deterministic order.""" literals = sorted( - (value for value in graph.objects(subject, predicate) if isinstance(value, Literal)), - key=lambda value: ( - 0 if value.language == "en" else 1 if value.language is None else 2, - value.language or "", - str(value), + ( + literal_value + for literal_value in graph.objects(subject, predicate) + if isinstance(literal_value, Literal) + ), + key=lambda literal_value: ( + 0 + if literal_value.language == "en" + else 1 + if literal_value.language is None + else 2, + literal_value.language or "", + str(literal_value), ), ) return str(literals[0]) if literals else None -def _canonicalize_json(value: Any, parent_key: str | None = None) -> Any: +def _canonicalize_json(json_value: Any, parent_key: str | None = None) -> Any: """Canonicalize JSON-LD while preserving explicit ``@list`` ordering.""" - if isinstance(value, dict): - return {key: _canonicalize_json(value[key], key) for key in sorted(value)} - if isinstance(value, list): - canonical = [_canonicalize_json(item, parent_key) for item in value] + if isinstance(json_value, dict): + return { + json_key: _canonicalize_json(json_value[json_key], json_key) + for json_key in sorted(json_value) + } + if isinstance(json_value, list): + canonical_items = [ + _canonicalize_json(json_item, parent_key) for json_item in json_value + ] if parent_key == "@list": - return canonical + return canonical_items return sorted( - canonical, - key=lambda item: json.dumps( - item, + canonical_items, + key=lambda json_item: json.dumps( + json_item, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ), ) - return value + return json_value def _write_serializations(graph: Graph, ontology_dir: Path) -> None: @@ -132,12 +145,12 @@ def _write_serializations(graph: Graph, ontology_dir: Path) -> None: ) -def _render_link(value: URIRef, ontology_subjects: set[URIRef]) -> str: +def _render_link(ontology_resource: URIRef, ontology_subjects: set[URIRef]) -> str: """Render a local term link or a non-navigating external RDF identifier.""" - if value not in ontology_subjects: - return f"{html.escape(str(value))}" - href = html.escape(f"#{public_fragment(_fragment(value))}", quote=True) - return f'{html.escape(_fragment(value))}' + if ontology_resource not in ontology_subjects: + return f"{html.escape(str(ontology_resource))}" + href = html.escape(f"#{public_fragment(_fragment(ontology_resource))}", quote=True) + return f'{html.escape(_fragment(ontology_resource))}' def _render_relation_rows( @@ -146,17 +159,26 @@ def _render_relation_rows( ontology_subjects: set[URIRef], ) -> str: """Render standard semantic relations for one term.""" - rows: list[str] = [] + relation_rows: list[str] = [] for heading, predicate in RELATION_FIELDS: - values = sorted( - (value for value in graph.objects(subject, predicate) if isinstance(value, URIRef)), + relation_targets = sorted( + ( + ontology_resource + for ontology_resource in graph.objects(subject, predicate) + if isinstance(ontology_resource, URIRef) + ), key=str, ) - if not values: + if not relation_targets: continue - rendered = ", ".join(_render_link(value, ontology_subjects) for value in values) - rows.append(f"
{html.escape(heading)}
{rendered}
") - return "".join(rows) + rendered_links = ", ".join( + _render_link(ontology_resource, ontology_subjects) + for ontology_resource in relation_targets + ) + relation_rows.append( + f"
{html.escape(heading)}
{rendered_links}
" + ) + return "".join(relation_rows) def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: @@ -172,13 +194,21 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) graph, subject, RDFS.comment ) lookup_predicate = URIRef(CANONICAL_LOOKUP_PREDICATE) - lookup_codes = sorted(str(value) for value in graph.objects(subject, lookup_predicate)) + lookup_codes = sorted( + str(lookup_value) for lookup_value in graph.objects(subject, lookup_predicate) + ) type_values = sorted( - (value for value in graph.objects(subject, RDF.type) if isinstance(value, URIRef)), + ( + type_value + for type_value in graph.objects(subject, RDF.type) + if isinstance(type_value, URIRef) + ), key=str, ) relation_rows = _render_relation_rows(graph, subject, ontology_subjects) - type_links = ", ".join(_render_link(value, ontology_subjects) for value in type_values) + type_links = ", ".join( + _render_link(type_value, ontology_subjects) for type_value in type_values + ) lookup_row = ( "
Lookup code
" + "".join(f"{html.escape(code)}" for code in lookup_codes) @@ -187,12 +217,12 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) else "" ) fja_rows = "".join( - f"
{heading}
{html.escape(value)}
" + f"
{heading}
{html.escape(fja_value)}
" for heading, predicate in ( ("FJA domain", CANONICAL_FJA_DOMAIN_PREDICATE), ("FJA rank", CANONICAL_FJA_RANK_PREDICATE), ) - if (value := _preferred_literal(graph, subject, predicate)) is not None + if (fja_value := _preferred_literal(graph, subject, predicate)) is not None ) comment_html = ( f'

{html.escape(comment)}

' if comment else "" @@ -419,7 +449,7 @@ def _write_manifest( term_count: int, ) -> None: """Write deterministic provenance metadata for the published ontology.""" - payload = { + manifest_payload = { "documentation_url": DOCUMENTATION_URL, "generated_artifacts": [ "index.html", @@ -438,7 +468,7 @@ def _write_manifest( "source_sha256": _sha256(source), } (ontology_dir / "manifest.json").write_text( - json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + json.dumps(manifest_payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) @@ -497,26 +527,26 @@ def build_site(repository_root: Path, output_dir: Path) -> None: def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: """Parse command-line arguments for repository and output locations.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( + site_build_parser = argparse.ArgumentParser(description=__doc__) + site_build_parser.add_argument( "--repository-root", type=Path, default=Path(__file__).resolve().parents[1], help="LineageWeave repository root (default: inferred from this script)", ) - parser.add_argument( + site_build_parser.add_argument( "--output-dir", type=Path, default=Path("_site"), help="Static site output directory (default: _site)", ) - return parser.parse_args(argv) + return site_build_parser.parse_args(argv) def main(argv: Iterable[str] | None = None) -> int: """Build the site from CLI arguments and return a process exit code.""" - args = _parse_args(argv) - build_site(args.repository_root, args.output_dir) + command_arguments = _parse_args(argv) + build_site(command_arguments.repository_root, command_arguments.output_dir) return 0 diff --git a/tests/test_build_ontology_site_semantic_identifiers.py b/tests/test_build_ontology_site_semantic_identifiers.py index 413bc4526..b4c0e0f05 100644 --- a/tests/test_build_ontology_site_semantic_identifiers.py +++ b/tests/test_build_ontology_site_semantic_identifiers.py @@ -5,7 +5,6 @@ import ast from pathlib import Path - SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "build_ontology_site.py" From ac027823dce08c59fbc0515cf767e651840116ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:27:53 +0900 Subject: [PATCH 39/51] test(operators): require semantic requeue identifiers --- ...ailed_post_content_semantic_identifiers.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_requeue_failed_post_content_semantic_identifiers.py diff --git a/tests/test_requeue_failed_post_content_semantic_identifiers.py b/tests/test_requeue_failed_post_content_semantic_identifiers.py new file mode 100644 index 000000000..49119da97 --- /dev/null +++ b/tests/test_requeue_failed_post_content_semantic_identifiers.py @@ -0,0 +1,74 @@ +"""Naming and boundary contracts for explicit post-content requeue.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +SCRIPT_PATH = Path("scripts/requeue_failed_post_content.py") + + +def test_post_content_requeue_uses_semantic_owned_identifiers() -> None: + """Keep command, database, queue, request, and settings names specific.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + syntax_tree = ast.parse(script_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_parser", + "args", + "client", + "connection", + "parser", + "request", + "settings", + } + ) + assert { + "_post_content_requeue_parser", + "command_arguments", + "command_parser", + "database_connection", + "post_content_job_request", + "runtime_settings", + "source_post_body_row", + "valkey_client", + "valkey_stream_entry_id", + } <= owned_identifiers + + +def test_post_content_requeue_preserves_operator_and_resource_contracts() -> None: + """Keep CLI, SQL, output, publication, and close behavior stable.""" + script_source = SCRIPT_PATH.read_text(encoding="utf-8") + + for contract_literal in ( + '"--post-id"', + '"--target-dsn"', + '"--valkey-url"', + '"select post_body from source_post where post_id = $1::uuid"', + '"post_id"', + '"status"', + '"published"', + "requeue_failed_post_content_job(", + "publish_post_content_event(", + "await database_connection.close()", + "await valkey_client.aclose()", + ): + assert contract_literal in script_source + assert "asyncio.run(\n requeue_post_content(" in script_source From 321f5218fde1d570c1592e4fa60b7630d536b934 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:28:12 +0900 Subject: [PATCH 40/51] refactor(operators): name explicit requeue boundaries --- CHANGELOG.md | 5 + docs/product-technical-gap-baseline.md | 13 ++ scripts/requeue_failed_post_content.py | 60 ++++--- ...ailed_post_content_semantic_identifiers.py | 154 +++++++++++++++++- 4 files changed, 204 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3164837f..a5f4f3c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The explicit post-content requeue operator now uses semantic command, + database, source-post, job-request, Valkey-stream, and runtime-settings + identifiers while preserving CLI flags, SQL, transaction, publication, + JSON output keys, and resource-close behavior. + - The deterministic ontology-site builder now uses semantic ontology-resource, JSON-LD item, relation-row, manifest-payload, and CLI identifiers while preserving public URLs, formats, manifest fields, generated bytes, and flags. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ecfb0a564..be5505c22 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,18 @@ # Product & Technical Gap Baseline +> Explicit post-content requeue naming overlay: 2026-09-08 KST. Protected +> `main` is `83eba56149eb802cd63642c507c324c9976ec78e`; exact RED head +> `c92ee08effb128e1a5de0277f9c0da43ffa639ed` found repository-owned +> `_parser`, `parser`, `connection`, `client`, `request`, `args`, and `settings` +> identifiers across the operator's CLI, PostgreSQL transaction, Valkey +> publication, and runtime configuration. Action: align the complete private +> caller surface with post-content-requeue, database, source-post, +> job-request, Valkey-stream, and runtime-settings language while preserving +> CLI flags, SQL, JSON output keys, transaction, publication, and resource +> close behavior. Status: RED reproduced; implementation and focused naming, +> boundary, compile, and lint validation GREEN locally; GitHub exact-head +> checks and independent review pending. +> > Ontology-site builder naming overlay: 2026-09-08 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`; exact RED head > `fbde01a4696362d659db2f4b33f588882bdb8385` found repository-owned diff --git a/scripts/requeue_failed_post_content.py b/scripts/requeue_failed_post_content.py index 6747524f8..eac492949 100644 --- a/scripts/requeue_failed_post_content.py +++ b/scripts/requeue_failed_post_content.py @@ -21,15 +21,15 @@ ) -def _parser() -> argparse.ArgumentParser: +def _post_content_requeue_parser() -> argparse.ArgumentParser: """Build the operator-only command-line parser.""" - parser = argparse.ArgumentParser( + command_parser = argparse.ArgumentParser( description="Explicitly requeue one failed post-content ingestion job." ) - parser.add_argument("--post-id", required=True) - parser.add_argument("--target-dsn") - parser.add_argument("--valkey-url") - return parser + command_parser.add_argument("--post-id", required=True) + command_parser.add_argument("--target-dsn") + command_parser.add_argument("--valkey-url") + return command_parser async def requeue_post_content( @@ -39,43 +39,49 @@ async def requeue_post_content( valkey_url: str, ) -> None: """Reset one failed job, append its audit event, and publish its wake-up.""" - connection = await asyncpg.connect(target_dsn) - client = redis.from_url(valkey_url, decode_responses=True) + database_connection = await asyncpg.connect(target_dsn) + valkey_client = redis.from_url(valkey_url, decode_responses=True) try: - body_row = await connection.fetchrow( + source_post_body_row = await database_connection.fetchrow( "select post_body from source_post where post_id = $1::uuid", post_id, ) - if body_row is None: + if source_post_body_row is None: raise ValueError(f"source post does not exist: {post_id}") - async with connection.transaction(): - request = await requeue_failed_post_content_job( - connection, + async with database_connection.transaction(): + post_content_job_request = await requeue_failed_post_content_job( + database_connection, post_id, - str(body_row["post_body"] or ""), + str(source_post_body_row["post_body"] or ""), ) - entry_id = await publish_post_content_event( - client, - post_id=request.post_id, - source_body_digest=request.source_body_sha256, + valkey_stream_entry_id = await publish_post_content_event( + valkey_client, + post_id=post_content_job_request.post_id, + source_body_digest=post_content_job_request.source_body_sha256, ) - if entry_id is None: + if valkey_stream_entry_id is None: raise RuntimeError("Valkey did not publish the explicit retry wake-up") - print({"post_id": post_id, "status": request.status_code, "published": True}) + print( + { + "post_id": post_id, + "status": post_content_job_request.status_code, + "published": True, + } + ) finally: - await connection.close() - await client.aclose() + await database_connection.close() + await valkey_client.aclose() def main() -> None: """Parse the target and run one explicit terminal-job recovery.""" - args = _parser().parse_args() - settings = load_settings() + command_arguments = _post_content_requeue_parser().parse_args() + runtime_settings = load_settings() asyncio.run( requeue_post_content( - args.post_id, - target_dsn=args.target_dsn or settings.database_url, - valkey_url=args.valkey_url or settings.valkey_url, + command_arguments.post_id, + target_dsn=command_arguments.target_dsn or runtime_settings.database_url, + valkey_url=command_arguments.valkey_url or runtime_settings.valkey_url, ) ) diff --git a/tests/test_requeue_failed_post_content_semantic_identifiers.py b/tests/test_requeue_failed_post_content_semantic_identifiers.py index 49119da97..93382363a 100644 --- a/tests/test_requeue_failed_post_content_semantic_identifiers.py +++ b/tests/test_requeue_failed_post_content_semantic_identifiers.py @@ -3,12 +3,164 @@ from __future__ import annotations import ast +import asyncio +import contextlib +import io +import runpy +import sys +import types from pathlib import Path - +from types import SimpleNamespace +from unittest.mock import patch SCRIPT_PATH = Path("scripts/requeue_failed_post_content.py") +class _DatabaseTransaction: + """Record entry and exit of the database transaction boundary.""" + + def __init__(self, operation_events: list[str]) -> None: + self.operation_events = operation_events + + async def __aenter__(self) -> None: + self.operation_events.append("transaction_entered") + + async def __aexit__(self, *_exception_details: object) -> None: + self.operation_events.append("transaction_exited") + + +class _DatabaseConnection: + """Minimal asyncpg-compatible connection for the operator contract.""" + + def __init__(self, operation_events: list[str]) -> None: + self.operation_events = operation_events + self.connection_closed = False + + async def fetchrow(self, query_text: str, post_identifier: str) -> dict[str, str]: + """Return one source post body and record the bound query.""" + self.operation_events.append(f"fetch:{post_identifier}:{query_text}") + return {"post_body": "bounded body"} + + def transaction(self) -> _DatabaseTransaction: + """Return the recorded transaction context.""" + return _DatabaseTransaction(self.operation_events) + + async def close(self) -> None: + """Record database resource closure.""" + self.connection_closed = True + + +class _ValkeyClient: + """Minimal redis-compatible client for the publication contract.""" + + def __init__(self) -> None: + self.client_closed = False + + async def aclose(self) -> None: + """Record Valkey resource closure.""" + self.client_closed = True + + +def _load_requeue_script( + database_connection: _DatabaseConnection, + valkey_client: _ValkeyClient, + operation_events: list[str], +) -> dict[str, object]: + """Load the operator with bounded database and Valkey adapters.""" + + async def connect_database(_target_dsn: str) -> _DatabaseConnection: + return database_connection + + async def requeue_failed_job( + received_connection: _DatabaseConnection, + post_identifier: str, + source_post_body: str, + ) -> SimpleNamespace: + assert received_connection is database_connection + operation_events.append(f"requeued:{post_identifier}:{source_post_body}") + return SimpleNamespace( + post_id=post_identifier, + source_body_sha256="digest", + status_code="pending", + ) + + async def publish_retry_event( + received_client: _ValkeyClient, + *, + post_id: str, + source_body_digest: str, + ) -> str: + assert received_client is valkey_client + operation_events.append(f"published:{post_id}:{source_body_digest}") + return "stream-entry-1" + + asyncpg_module = types.ModuleType("asyncpg") + asyncpg_module.connect = connect_database # type: ignore[attr-defined] + redis_package = types.ModuleType("redis") + redis_package.__path__ = [] # type: ignore[attr-defined] + redis_asyncio_module = types.ModuleType("redis.asyncio") + redis_asyncio_module.from_url = ( # type: ignore[attr-defined] + lambda _valkey_url, *, decode_responses: valkey_client + ) + redis_package.asyncio = redis_asyncio_module # type: ignore[attr-defined] + backend_package = types.ModuleType("backend") + backend_package.__path__ = [] # type: ignore[attr-defined] + backend_app_package = types.ModuleType("backend.app") + backend_app_package.__path__ = [] # type: ignore[attr-defined] + config_module = types.ModuleType("backend.app.config") + config_module.load_settings = lambda: None # type: ignore[attr-defined] + queue_module = types.ModuleType("backend.app.post_content_queue") + queue_module.requeue_failed_post_content_job = requeue_failed_job # type: ignore[attr-defined] + queue_module.publish_post_content_event = publish_retry_event # type: ignore[attr-defined] + module_overrides = { + "asyncpg": asyncpg_module, + "redis": redis_package, + "redis.asyncio": redis_asyncio_module, + "backend": backend_package, + "backend.app": backend_app_package, + "backend.app.config": config_module, + "backend.app.post_content_queue": queue_module, + } + with patch.dict(sys.modules, module_overrides): + return runpy.run_path(str(SCRIPT_PATH), run_name="requeue_script_test") + + +def test_post_content_requeue_preserves_transaction_publication_and_close() -> None: + """Publish only after the transaction and close both owned resources.""" + operation_events: list[str] = [] + database_connection = _DatabaseConnection(operation_events) + valkey_client = _ValkeyClient() + script_namespace = _load_requeue_script( + database_connection, + valkey_client, + operation_events, + ) + output_buffer = io.StringIO() + + with contextlib.redirect_stdout(output_buffer): + asyncio.run( + script_namespace["requeue_post_content"]( + "post-1", + target_dsn="postgresql://lineageweave", + valkey_url="redis://lineageweave", + ) + ) + + assert operation_events[1:] == [ + "transaction_entered", + "requeued:post-1:bounded body", + "transaction_exited", + "published:post-1:digest", + ] + assert database_connection.connection_closed is True + assert valkey_client.client_closed is True + assert ast.literal_eval(output_buffer.getvalue()) == { + "post_id": "post-1", + "status": "pending", + "published": True, + } + + def test_post_content_requeue_uses_semantic_owned_identifiers() -> None: """Keep command, database, queue, request, and settings names specific.""" script_source = SCRIPT_PATH.read_text(encoding="utf-8") From 759186ada23647351b140e6bbc974346628a2d30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:58:16 +0900 Subject: [PATCH 41/51] fix(ddd): preserve orchestrator owner boundary in keyman backfill --- scripts/backfill_post_keymen.py | 35 +++++++++++---------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/scripts/backfill_post_keymen.py b/scripts/backfill_post_keymen.py index 0d55d1fe8..ef375d2e9 100644 --- a/scripts/backfill_post_keymen.py +++ b/scripts/backfill_post_keymen.py @@ -36,29 +36,16 @@ from lineageweave.post_content_normalization import normalize_post_body -def _first_env(*variable_names: str) -> str: - """Return the first non-empty configured environment value.""" - return next( - ( - os.environ.get(variable_name, "").strip() - for variable_name in variable_names - if os.environ.get(variable_name, "").strip() - ), - "", - ) - - def _orchestrator_config() -> tuple[str, str]: - """Resolve the contextual-orchestrator endpoint and credential.""" - base_url = _first_env( - "ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL" - ) - api_key = _first_env("ORCHESTRATOR_API_KEY", "LLM_GATEWAY_API_KEY") - if not base_url or not api_key: + """Return the published contextual-orchestrator consumer endpoint and bearer.""" + orchestrator_base_url = os.environ.get("ORCHESTRATOR_BASE_URL", "").strip() + orchestrator_api_key = os.environ.get("ORCHESTRATOR_API_KEY", "").strip() + if not orchestrator_base_url or not orchestrator_api_key: raise RuntimeError( - "contextual-orchestrator gateway configuration is unavailable" + "set ORCHESTRATOR_BASE_URL and ORCHESTRATOR_API_KEY to reach " + "contextual-orchestrator" ) - return base_url, api_key + return orchestrator_base_url, orchestrator_api_key async def _select_posts( @@ -181,12 +168,14 @@ async def _run_post_keyman_backfill( """Run the bounded post-Keyman backfill transaction.""" if command_arguments.post_id and command_arguments.all: raise ValueError("--post-id and --all cannot be combined") - base_url, api_key = _orchestrator_config() + orchestrator_base_url, orchestrator_api_key = _orchestrator_config() runtime_settings = load_settings() keyman_client = ContextualOrchestratorKeymanExtractionClient( - base_url=base_url, api_key=api_key, timeout=180.0 + base_url=orchestrator_base_url, api_key=orchestrator_api_key, timeout=180.0 + ) + vision_client = orchestrator_vision_client( + orchestrator_base_url, orchestrator_api_key ) - vision_client = orchestrator_vision_client(base_url, api_key) resolution_client = _organization_name_resolution_client() verification_client = _relation_verification_client() hierarchy_client = _corporate_hierarchy_inference_client() From 104d9fc40820ac7062a8bdbb839b698dd556a4a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:59:09 +0900 Subject: [PATCH 42/51] fix(ddd): preserve orchestrator owner boundary in LLM estimator --- scripts/estimate_llm_channel_weights.py | 30 +++++-------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/scripts/estimate_llm_channel_weights.py b/scripts/estimate_llm_channel_weights.py index 1beef15f1..463709cc0 100644 --- a/scripts/estimate_llm_channel_weights.py +++ b/scripts/estimate_llm_channel_weights.py @@ -54,33 +54,13 @@ def _orchestrator_config() -> tuple[str, str]: - """Base URL and bearer key for the batch routing API, from the environment.""" - orchestrator_base_url = next( - ( - os.environ[environment_variable_name].strip() - for environment_variable_name in ( - "ORCHESTRATOR_BASE_URL", - "LLM_GATEWAY_API_URL", - ) - if os.environ.get(environment_variable_name, "").strip() - ), - "", - ) - orchestrator_api_key = next( - ( - os.environ[environment_variable_name].strip() - for environment_variable_name in ( - "ORCHESTRATOR_API_KEY", - "CONTEXTUAL_ORCHESTRATOR_TOKEN", - ) - if os.environ.get(environment_variable_name, "").strip() - ), - "", - ) + """Return the published contextual-orchestrator consumer endpoint and bearer.""" + orchestrator_base_url = os.environ.get("ORCHESTRATOR_BASE_URL", "").strip() + orchestrator_api_key = os.environ.get("ORCHESTRATOR_API_KEY", "").strip() if not orchestrator_base_url or not orchestrator_api_key: raise RuntimeError( - "set ORCHESTRATOR_BASE_URL and ORCHESTRATOR_API_KEY (or " - "CONTEXTUAL_ORCHESTRATOR_TOKEN) to reach the batch routing API" + "set ORCHESTRATOR_BASE_URL and ORCHESTRATOR_API_KEY to reach " + "the contextual-orchestrator batch routing API" ) return orchestrator_base_url.rstrip("/"), orchestrator_api_key From 7a1d770c6c5e5ee90b47b4e2a25ccdf6f506e668 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:59:46 +0900 Subject: [PATCH 43/51] test(ontology): require semantic namespace migration names --- tests/test_migrate_legacy_namespace.py | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_migrate_legacy_namespace.py b/tests/test_migrate_legacy_namespace.py index e49093a2e..187edcfe2 100644 --- a/tests/test_migrate_legacy_namespace.py +++ b/tests/test_migrate_legacy_namespace.py @@ -8,6 +8,7 @@ from __future__ import annotations +import ast import asyncio import importlib.util from pathlib import Path @@ -23,6 +24,60 @@ LEGACY = migrate_legacy_namespace.LEGACY_NAMESPACE +def test_migration_operator_uses_semantic_owned_identifiers() -> None: + """Keep migration, database, IRI, and command names context-specific.""" + module_source = _SCRIPT.read_text(encoding="utf-8") + syntax_tree = ast.parse(module_source) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.AsyncFunctionDef, ast.FunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "apply", + "args", + "canonical", + "canonicalize", + "change", + "conn", + "dsn", + "iri", + "migrate", + "new", + "parser", + "planned", + "row", + "rows", + "unexpected", + "updated", + } + ) + assert { + "apply_changes", + "canonicalize_ontology_iri", + "command_arguments", + "command_parser", + "database_connection", + "migrate_legacy_ontology_namespace", + "ontology_iri", + "planned_iri_rewrites", + "source_mention_rows", + "unexpected_namespace_records", + } <= owned_identifiers + + class TestCanonicalize: def test_maps_legacy_to_canonical(self) -> None: assert migrate_legacy_namespace.canonicalize(f"{LEGACY}Project") == f"{CANONICAL}Project" From 8dffc521dd7c69366cf6e6f18fa4d4c85db929a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 15:59:56 +0900 Subject: [PATCH 44/51] refactor(ontology): name namespace migration boundaries --- CHANGELOG.md | Bin 119725 -> 90060 bytes docs/product-technical-gap-baseline.md | 14 ++ scripts/migrate_legacy_namespace.py | 131 ++++++++----- tests/test_migrate_legacy_namespace.py | 250 ++++++++++++++++--------- 4 files changed, 260 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f4f3c53c00a280606be57b20ffb4d404082d24..fee49c8afd66a2eb567c3e339fcc1e781f99ebb4 100644 GIT binary patch literal 90060 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 Z6v!GDj;^~x2SA!Hru39Hc8=J@{{NG5>Fodj literal 119725 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�`oU00{AM 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

?#dSPjBsy>+*dYL=tcTVWCL0Ln`+ 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 Legacy ontology-namespace migration naming overlay: 2026-09-08 KST. +> Protected `main` is `83eba56149eb802cd63642c507c324c9976ec78e`; +> exact RED head `5c7e288605cf091c1300905af2df77509458529d` +> found repository-owned `canonicalize`, `migrate`, `iri`, `dsn`, `apply`, +> `conn`, `rows`, `row`, `planned`, `unexpected`, `parser`, and `args` +> identifiers across the operator, PostgreSQL transaction, and behavioral +> fixture. Action: align the complete caller surface with ontology-IRI, +> legacy-namespace migration, database, source-mention, rewrite-plan, and +> command language while preserving CLI flags, SQL, dry-run/fail-closed +> output, idempotence, transactional updates, and connection close. Status: +> RED reproduced; implementation and focused behavior, naming, compile, lint, +> and format validation GREEN locally; GitHub exact-head checks and independent +> review pending. +> > Explicit post-content requeue naming overlay: 2026-09-08 KST. Protected > `main` is `83eba56149eb802cd63642c507c324c9976ec78e`; exact RED head > `c92ee08effb128e1a5de0277f9c0da43ffa639ed` found repository-owned diff --git a/scripts/migrate_legacy_namespace.py b/scripts/migrate_legacy_namespace.py index eed95009f..9b8f52101 100644 --- a/scripts/migrate_legacy_namespace.py +++ b/scripts/migrate_legacy_namespace.py @@ -42,26 +42,29 @@ LEGACY_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" -def canonicalize(iri: str) -> str | None: - """Return the canonical spelling of ``iri``, or None if not legacy.""" - if iri.startswith(LEGACY_NAMESPACE): - return CANONICAL_NAMESPACE + iri[len(LEGACY_NAMESPACE):] +def canonicalize_ontology_iri(ontology_iri: str) -> str | None: + """Return the canonical spelling of an IRI, or None if it is not legacy.""" + if ontology_iri.startswith(LEGACY_NAMESPACE): + return CANONICAL_NAMESPACE + ontology_iri[len(LEGACY_NAMESPACE) :] return None -async def migrate(dsn: str, apply: bool) -> int: +async def migrate_legacy_ontology_namespace( + target_dsn: str, + apply_changes: bool, +) -> int: """Scan, report, and optionally rewrite legacy namespace IRIs. Args: - dsn: PostgreSQL DSN for the target database. - apply: False for dry-run reporting; True to execute the rewrite. + target_dsn: PostgreSQL DSN for the target database. + apply_changes: False for dry-run reporting; True to execute the rewrite. Returns: Process exit code: 0 when clean or migrated, 1 on unexpected IRIs. """ - conn = await asyncpg.connect(dsn) + database_connection = await asyncpg.connect(target_dsn) try: - rows = await conn.fetch( + source_mention_rows = await database_connection.fetch( """ select post_id, project_name, ontology_iri from post_project_mention @@ -69,39 +72,62 @@ async def migrate(dsn: str, apply: bool) -> int: order by post_id, project_name """ ) - unexpected: list[tuple[str, str, str]] = [] - planned: list[tuple[str, str, str]] = [] - for row in rows: - iri = row["ontology_iri"] - canonical = canonicalize(iri) - if canonical is None: - if not iri.startswith(CANONICAL_NAMESPACE): - unexpected.append((row["post_id"], row["project_name"], iri)) + unexpected_namespace_records: list[tuple[str, str, str]] = [] + planned_iri_rewrites: list[tuple[str, str, str]] = [] + for source_mention_row in source_mention_rows: + ontology_iri = source_mention_row["ontology_iri"] + canonical_ontology_iri = canonicalize_ontology_iri(ontology_iri) + if canonical_ontology_iri is None: + if not ontology_iri.startswith(CANONICAL_NAMESPACE): + unexpected_namespace_records.append( + ( + source_mention_row["post_id"], + source_mention_row["project_name"], + ontology_iri, + ) + ) continue - planned.append((row["post_id"], row["project_name"], f"{iri} -> {canonical}")) + planned_iri_rewrites.append( + ( + source_mention_row["post_id"], + source_mention_row["project_name"], + f"{ontology_iri} -> {canonical_ontology_iri}", + ) + ) - print(f"scanned {len(rows)} mention row(s) with a non-null ontology_iri") - for post_id, project_name, change in planned: - print(f" {post_id} / {project_name}: {change}") - for post_id, project_name, iri in unexpected: + print( + f"scanned {len(source_mention_rows)} mention row(s) " + "with a non-null ontology_iri" + ) + for post_id, project_name, iri_rewrite_description in planned_iri_rewrites: + print(f" {post_id} / {project_name}: {iri_rewrite_description}") + for post_id, project_name, ontology_iri in unexpected_namespace_records: print( - f" UNEXPECTED {post_id} / {project_name}: {iri} " + f" UNEXPECTED {post_id} / {project_name}: {ontology_iri} " f"(neither namespace; left untouched)" ) - if unexpected: - print(f"{len(unexpected)} row(s) carry an unrecognized namespace; nothing written") + if unexpected_namespace_records: + print( + f"{len(unexpected_namespace_records)} row(s) carry an unrecognized " + "namespace; nothing written" + ) return 1 - if not planned: + if not planned_iri_rewrites: print("no legacy namespace rows remain") return 0 - if not apply: - print(f"dry run: {len(planned)} row(s) would be rewritten; pass --apply to write") + if not apply_changes: + print( + f"dry run: {len(planned_iri_rewrites)} row(s) would be rewritten; " + "pass --apply to write" + ) return 0 - async with conn.transaction(): - for post_id, project_name, change in planned: - _old, _, new = change.rpartition(" -> ") - updated = await conn.execute( + async with database_connection.transaction(): + for post_id, project_name, iri_rewrite_description in planned_iri_rewrites: + _legacy_ontology_iri, _, canonical_ontology_iri = ( + iri_rewrite_description.rpartition(" -> ") + ) + database_update_result = await database_connection.execute( """ update post_project_mention set ontology_iri = $3 @@ -109,28 +135,45 @@ async def migrate(dsn: str, apply: bool) -> int: """, post_id, project_name, - new, - new.replace(CANONICAL_NAMESPACE, LEGACY_NAMESPACE), + canonical_ontology_iri, + canonical_ontology_iri.replace( + CANONICAL_NAMESPACE, + LEGACY_NAMESPACE, + ), ) - if updated != "UPDATE 1": - raise RuntimeError(f"row changed during migration: {post_id}/{project_name}") - print(f"applied: {len(planned)} row(s) rewritten to the canonical namespace") + if database_update_result != "UPDATE 1": + raise RuntimeError( + f"row changed during migration: {post_id}/{project_name}" + ) + print( + f"applied: {len(planned_iri_rewrites)} row(s) rewritten " + "to the canonical namespace" + ) return 0 finally: - await conn.close() + await database_connection.close() -def main(argv: list[str] | None = None) -> int: +def main(raw_arguments: list[str] | None = None) -> int: """CLI entry point.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--dsn", required=True, help="PostgreSQL DSN for the target database") - parser.add_argument( + command_parser = argparse.ArgumentParser(description=__doc__) + command_parser.add_argument( + "--dsn", + required=True, + help="PostgreSQL DSN for the target database", + ) + command_parser.add_argument( "--apply", action="store_true", help="execute the rewrite; without this flag the tool only reports", ) - args = parser.parse_args(argv) - return __import__("asyncio").run(migrate(args.dsn, args.apply)) + command_arguments = command_parser.parse_args(raw_arguments) + return __import__("asyncio").run( + migrate_legacy_ontology_namespace( + command_arguments.dsn, + command_arguments.apply, + ) + ) if __name__ == "__main__": diff --git a/tests/test_migrate_legacy_namespace.py b/tests/test_migrate_legacy_namespace.py index 187edcfe2..4107e0a09 100644 --- a/tests/test_migrate_legacy_namespace.py +++ b/tests/test_migrate_legacy_namespace.py @@ -2,7 +2,7 @@ The migration must be deterministic, dry-run by default, refuse unknown namespaces, and never touch provenance columns. These tests exercise the -pure ``canonicalize`` mapping and the async scan/rewrite flow against an +pure ``canonicalize_ontology_iri`` mapping and the async scan/rewrite flow against an in-memory fake connection -- no live PostgreSQL required. """ @@ -15,18 +15,23 @@ import pytest -_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "migrate_legacy_namespace.py" -_spec = importlib.util.spec_from_file_location("migrate_legacy_namespace", _SCRIPT) -migrate_legacy_namespace = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(migrate_legacy_namespace) +MIGRATION_SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "migrate_legacy_namespace.py" +) +MIGRATION_MODULE_SPEC = importlib.util.spec_from_file_location( + "migrate_legacy_namespace", + MIGRATION_SCRIPT_PATH, +) +migrate_legacy_namespace = importlib.util.module_from_spec(MIGRATION_MODULE_SPEC) +MIGRATION_MODULE_SPEC.loader.exec_module(migrate_legacy_namespace) -CANONICAL = migrate_legacy_namespace.CANONICAL_NAMESPACE -LEGACY = migrate_legacy_namespace.LEGACY_NAMESPACE +CANONICAL_ONTOLOGY_NAMESPACE = migrate_legacy_namespace.CANONICAL_NAMESPACE +LEGACY_ONTOLOGY_NAMESPACE = migrate_legacy_namespace.LEGACY_NAMESPACE def test_migration_operator_uses_semantic_owned_identifiers() -> None: """Keep migration, database, IRI, and command names context-specific.""" - module_source = _SCRIPT.read_text(encoding="utf-8") + module_source = MIGRATION_SCRIPT_PATH.read_text(encoding="utf-8") syntax_tree = ast.parse(module_source) owned_identifiers = { syntax_node.id @@ -80,73 +85,86 @@ def test_migration_operator_uses_semantic_owned_identifiers() -> None: class TestCanonicalize: def test_maps_legacy_to_canonical(self) -> None: - assert migrate_legacy_namespace.canonicalize(f"{LEGACY}Project") == f"{CANONICAL}Project" + assert ( + migrate_legacy_namespace.canonicalize_ontology_iri( + f"{LEGACY_ONTOLOGY_NAMESPACE}Project" + ) + == f"{CANONICAL_ONTOLOGY_NAMESPACE}Project" + ) def test_canonical_rows_are_left_alone(self) -> None: - iri = f"{CANONICAL}Person" - assert migrate_legacy_namespace.canonicalize(iri) is None + ontology_iri = f"{CANONICAL_ONTOLOGY_NAMESPACE}Person" + assert migrate_legacy_namespace.canonicalize_ontology_iri(ontology_iri) is None def test_unknown_namespaces_return_none(self) -> None: - assert migrate_legacy_namespace.canonicalize("https://example.com/other#Thing") is None + assert ( + migrate_legacy_namespace.canonicalize_ontology_iri( + "https://example.com/other#Thing" + ) + is None + ) def test_fragment_is_preserved_exactly(self) -> None: - term = "CorporateEntity" - mapped = migrate_legacy_namespace.canonicalize(f"{LEGACY}{term}") - assert mapped == f"{CANONICAL}{term}" - assert mapped.endswith(term) + ontology_term = "CorporateEntity" + mapped_ontology_iri = migrate_legacy_namespace.canonicalize_ontology_iri( + f"{LEGACY_ONTOLOGY_NAMESPACE}{ontology_term}" + ) + assert mapped_ontology_iri == f"{CANONICAL_ONTOLOGY_NAMESPACE}{ontology_term}" + assert mapped_ontology_iri.endswith(ontology_term) class FakeRecord: def __init__(self, post_id: str, project_name: str, ontology_iri: str): - self._data = { + self._record_values = { "post_id": post_id, "project_name": project_name, "ontology_iri": ontology_iri, } - def __getitem__(self, key: str): - return self._data[key] + def __getitem__(self, record_field: str): + return self._record_values[record_field] @pytest.fixture() def _patch_connect(monkeypatch: pytest.MonkeyPatch): """Route asyncpg.connect to a factory over a caller-supplied connection.""" - holder: dict = {} + connection_factory_state: dict = {} - def _factory(conn): - def _connect(dsn): - assert "postgresql://" in dsn - return _AsyncReturn(conn) - holder["conn"] = conn - return _connect + def _connection_factory(database_connection): + def connect_database(target_dsn): + assert "postgresql://" in target_dsn + return _AsyncReturn(database_connection) - holder["factory"] = _factory - yield holder + connection_factory_state["database_connection"] = database_connection + return connect_database + + connection_factory_state["connection_factory"] = _connection_factory + yield connection_factory_state class _AsyncReturn: """Awaitable that resolves immediately.""" - def __init__(self, value): - self._value = value + def __init__(self, awaited_value): + self._awaited_value = awaited_value def __await__(self): if False: yield - return self._value + return self._awaited_value class FakeConnection: """Minimal asyncpg surface: one select, transactional updates.""" - def __init__(self, rows: list[FakeRecord]): - self.rows = rows - self.updates: list[tuple] = [] + def __init__(self, mention_rows: list[FakeRecord]): + self.mention_rows = mention_rows + self.executed_updates: list[tuple] = [] self.transaction_entered = False async def fetch(self, query: str): assert "post_project_mention" in query - return self.rows + return self.mention_rows def transaction(self): return self @@ -160,67 +178,117 @@ async def __aexit__(self, *exc_info): async def execute(self, query: str, *args): assert "update post_project_mention" in query - self.updates.append(args) + self.executed_updates.append(args) return "UPDATE 1" async def close(self): pass -def test_dry_run_reports_without_writing(capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch) -> None: - rows = [ - FakeRecord("p1", "Alpha", f"{LEGACY}Project"), - FakeRecord("p2", "Beta", f"{CANONICAL}Team"), +def test_dry_run_reports_without_writing( + capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch +) -> None: + source_mention_rows = [ + FakeRecord("p1", "Alpha", f"{LEGACY_ONTOLOGY_NAMESPACE}Project"), + FakeRecord("p2", "Beta", f"{CANONICAL_ONTOLOGY_NAMESPACE}Team"), ] - conn = FakeConnection(rows) - monkeypatch.setattr(migrate_legacy_namespace.asyncpg, "connect", _patch_connect["factory"](conn)) - rc = asyncio.run(migrate_legacy_namespace.migrate("postgresql://unused", apply=False)) - - assert rc == 0 - out = capsys.readouterr().out - assert "dry run" in out - assert f"{LEGACY}Project -> {CANONICAL}Project" in out - assert conn.updates == [] - assert not conn.transaction_entered - - -def test_apply_rewrites_only_legacy_rows(_patch_connect, monkeypatch: pytest.MonkeyPatch) -> None: - rows = [ - FakeRecord("p1", "Alpha", f"{LEGACY}Project"), - FakeRecord("p2", "Beta", f"{CANONICAL}Team"), + database_connection = FakeConnection(source_mention_rows) + monkeypatch.setattr( + migrate_legacy_namespace.asyncpg, + "connect", + _patch_connect["connection_factory"](database_connection), + ) + exit_code = asyncio.run( + migrate_legacy_namespace.migrate_legacy_ontology_namespace( + "postgresql://unused", + apply_changes=False, + ) + ) + + assert exit_code == 0 + captured_output = capsys.readouterr().out + assert "dry run" in captured_output + assert ( + f"{LEGACY_ONTOLOGY_NAMESPACE}Project -> {CANONICAL_ONTOLOGY_NAMESPACE}Project" + ) in captured_output + assert database_connection.executed_updates == [] + assert not database_connection.transaction_entered + + +def test_apply_rewrites_only_legacy_rows( + _patch_connect, monkeypatch: pytest.MonkeyPatch +) -> None: + source_mention_rows = [ + FakeRecord("p1", "Alpha", f"{LEGACY_ONTOLOGY_NAMESPACE}Project"), + FakeRecord("p2", "Beta", f"{CANONICAL_ONTOLOGY_NAMESPACE}Team"), ] - conn = FakeConnection(rows) - monkeypatch.setattr(migrate_legacy_namespace.asyncpg, "connect", _patch_connect["factory"](conn)) - rc = asyncio.run(migrate_legacy_namespace.migrate("postgresql://unused", apply=True)) - - assert rc == 0 - assert len(conn.updates) == 1 - post_id, project_name, new_iri, old_iri = conn.updates[0] - assert (post_id, project_name) == ("p1", "Alpha") - assert new_iri == f"{CANONICAL}Project" - assert old_iri == f"{LEGACY}Project" - - -def test_unknown_namespace_fails_closed(capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch) -> None: - rows = [FakeRecord("p3", "Gamma", "https://example.com/weird#X")] - conn = FakeConnection(rows) - monkeypatch.setattr(migrate_legacy_namespace.asyncpg, "connect", _patch_connect["factory"](conn)) - rc = asyncio.run(migrate_legacy_namespace.migrate("postgresql://unused", apply=False)) - - assert rc == 1 - out = capsys.readouterr().out - assert "UNEXPECTED" in out - assert "nothing written" in out - assert conn.updates == [] - - -def test_clean_database_is_a_no_op(capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch) -> None: - rows = [FakeRecord("p4", "Delta", f"{CANONICAL}Post")] - conn = FakeConnection(rows) - monkeypatch.setattr(migrate_legacy_namespace.asyncpg, "connect", _patch_connect["factory"](conn)) - rc = asyncio.run(migrate_legacy_namespace.migrate("postgresql://unused", apply=True)) - - assert rc == 0 - out = capsys.readouterr().out - assert "no legacy namespace rows remain" in out - assert conn.updates == [] + database_connection = FakeConnection(source_mention_rows) + monkeypatch.setattr( + migrate_legacy_namespace.asyncpg, + "connect", + _patch_connect["connection_factory"](database_connection), + ) + exit_code = asyncio.run( + migrate_legacy_namespace.migrate_legacy_ontology_namespace( + "postgresql://unused", + apply_changes=True, + ) + ) + + assert exit_code == 0 + assert len(database_connection.executed_updates) == 1 + source_post_id, project_name, canonical_iri, legacy_iri = ( + database_connection.executed_updates[0] + ) + assert (source_post_id, project_name) == ("p1", "Alpha") + assert canonical_iri == f"{CANONICAL_ONTOLOGY_NAMESPACE}Project" + assert legacy_iri == f"{LEGACY_ONTOLOGY_NAMESPACE}Project" + + +def test_unknown_namespace_fails_closed( + capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch +) -> None: + source_mention_rows = [FakeRecord("p3", "Gamma", "https://example.com/weird#X")] + database_connection = FakeConnection(source_mention_rows) + monkeypatch.setattr( + migrate_legacy_namespace.asyncpg, + "connect", + _patch_connect["connection_factory"](database_connection), + ) + exit_code = asyncio.run( + migrate_legacy_namespace.migrate_legacy_ontology_namespace( + "postgresql://unused", + apply_changes=False, + ) + ) + + assert exit_code == 1 + captured_output = capsys.readouterr().out + assert "UNEXPECTED" in captured_output + assert "nothing written" in captured_output + assert database_connection.executed_updates == [] + + +def test_clean_database_is_a_no_op( + capsys: pytest.CaptureFixture[str], _patch_connect, monkeypatch: pytest.MonkeyPatch +) -> None: + source_mention_rows = [ + FakeRecord("p4", "Delta", f"{CANONICAL_ONTOLOGY_NAMESPACE}Post") + ] + database_connection = FakeConnection(source_mention_rows) + monkeypatch.setattr( + migrate_legacy_namespace.asyncpg, + "connect", + _patch_connect["connection_factory"](database_connection), + ) + exit_code = asyncio.run( + migrate_legacy_namespace.migrate_legacy_ontology_namespace( + "postgresql://unused", + apply_changes=True, + ) + ) + + assert exit_code == 0 + captured_output = capsys.readouterr().out + assert "no legacy namespace rows remain" in captured_output + assert database_connection.executed_updates == [] From 50ddbc64a4724e9eee5ddf12b9672503455a787c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:05:29 +0900 Subject: [PATCH 45/51] docs(changelog): record namespace migration naming repair --- CHANGELOG.md | Bin 90060 -> 120029 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee49c8afd66a2eb567c3e339fcc1e781f99ebb4..5a8b3ec52b15dbb48299c42f9f1ac90b7cbd1f28 100644 GIT binary patch literal 120029 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 Ky-)7s6 literal 90060 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 Z6v!GDj;^~x2SA!Hru39Hc8=J@{{NG5>Fodj From 1e077883e807f2e42f0b61743d99815469460af6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:20:47 +0900 Subject: [PATCH 46/51] test(ontology): require semantic publication identifiers --- ...lish_ontology_site_semantic_identifiers.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_publish_ontology_site_semantic_identifiers.py diff --git a/tests/test_publish_ontology_site_semantic_identifiers.py b/tests/test_publish_ontology_site_semantic_identifiers.py new file mode 100644 index 000000000..fb3617da4 --- /dev/null +++ b/tests/test_publish_ontology_site_semantic_identifiers.py @@ -0,0 +1,71 @@ +"""Naming contract for the governed ontology-site publisher.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "publish_ontology_site.py" + + +def test_ontology_site_publisher_uses_semantic_owned_identifiers() -> None: + """Keep publication, graph, compatibility, and CLI names specific.""" + syntax_tree = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8")) + owned_identifiers = { + syntax_node.id + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.Name) + } + owned_identifiers.update( + syntax_node.arg + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, ast.arg) + ) + owned_identifiers.update( + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ) + + assert owned_identifiers.isdisjoint( + { + "_fragment", + "_parse_args", + "args", + "canonical", + "compatibility", + "fragment", + "graph", + "iri", + "item", + "kind", + "kinds", + "mapping", + "mappings", + "module", + "output", + "parser", + "path", + "predicate", + "profile", + "renderer", + "requested", + "root", + "scheme", + "script", + "source", + "spec", + "subject", + "subjects", + "target", + "value", + } + ) + assert { + "canonical_ontology_graph", + "command_arguments", + "ontology_publication_parser", + "ontology_subject", + "publication_output_dir", + "repository_root", + } <= owned_identifiers From 56ea711c5c2dca531a8eff9f4c99fe3e43715571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:35:58 +0900 Subject: [PATCH 47/51] refactor(ontology): name publication boundaries --- CHANGELOG.md | 5 + docs/product-technical-gap-baseline.md | 15 ++ scripts/publish_ontology_site.py | 354 +++++++++++++++---------- tests/test_publish_ontology_site.py | 7 +- 4 files changed, 244 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8b3ec52..60c11f5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- The governed ontology-site publisher now uses semantic renderer, ontology + graph, namespace-mapping, SHACL-resource, source-path, output-path, and CLI + identifiers while preserving public function signatures, CLI flags, RDF + validation, fail-closed replacement, cleanup, and generated-site behavior. + - The legacy ontology-namespace migration operator and behavioral fixture now use semantic ontology-IRI, database, source-mention, rewrite-plan, and command identifiers while preserving CLI flags, SQL, dry-run/fail-closed output, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index bd9b2d0c8..29b71529a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,20 @@ # Product & Technical Gap Baseline +> Ontology-site publisher naming overlay: 2026-09-08 KST. The stacked base is +> `fix/contextual-orchestrator-owner-boundary@e5711282c48cc20d0a88fb56a9e382d500989c72`; +> exact RED head `1e077883e807f2e42f0b61743d99815469460af6` found +> repository-owned `_fragment`, `_parse_args`, `root`, `source`, `profile`, +> `graph`, `renderer`, `subject`, `predicate`, `value`, `path`, `output`, +> `parser`, and `args` identifiers across renderer loading, public-graph and +> namespace/SHACL validation, safe output replacement, and the CLI. Action: +> align the complete private caller surface with ontology-publication, +> renderer-module, ontology-resource, namespace-mapping, SHACL-resource, +> source-path, output-path, and command language while preserving public +> validation function names, CLI flags, RDF checks, fail-closed replacement, +> cleanup, and generated-site behavior. Status: RED reproduced; implementation, +> 16 focused behavior/naming tests, compile, and scoped lint/format GREEN +> locally; GitHub exact-head checks and independent review pending. +> > Legacy ontology-namespace migration naming overlay: 2026-09-08 KST. > Protected `main` is `83eba56149eb802cd63642c507c324c9976ec78e`; > exact RED head `5c7e288605cf091c1300905af2df77509458529d` diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 677ea4a92..d90c6195d 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -54,96 +54,146 @@ def _load_renderer(repository_root: Path) -> ModuleType: """Load the sibling deterministic renderer from one repository root.""" - script = repository_root / "scripts" / "build_ontology_site.py" - spec = importlib.util.spec_from_file_location("lineageweave_ontology_renderer", script) - if spec is None or spec.loader is None: - raise RuntimeError(f"ontology renderer could not be loaded: {script}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + renderer_script_path = repository_root / "scripts" / "build_ontology_site.py" + renderer_module_spec = importlib.util.spec_from_file_location( + "lineageweave_ontology_renderer", renderer_script_path + ) + if renderer_module_spec is None or renderer_module_spec.loader is None: + raise RuntimeError( + f"ontology renderer could not be loaded: {renderer_script_path}" + ) + renderer_module = importlib.util.module_from_spec(renderer_module_spec) + renderer_module_spec.loader.exec_module(renderer_module) + return renderer_module -def _fragment(value: URIRef) -> str: +def _ontology_fragment(ontology_resource: URIRef) -> str: """Return the local fragment used by the renderer as an HTML identifier.""" - iri = str(value) - if "#" in iri: - return iri.rsplit("#", 1)[1] - return iri.rstrip("/").rsplit("/", 1)[-1] + ontology_iri = str(ontology_resource) + if "#" in ontology_iri: + return ontology_iri.rsplit("#", 1)[1] + return ontology_iri.rstrip("/").rsplit("/", 1)[-1] -def _public_subjects(graph: Graph, renderer: ModuleType) -> set[URIRef]: +def _public_ontology_subjects( + ontology_graph: Graph, + ontology_renderer: ModuleType, +) -> set[URIRef]: """Return URI subjects included in the renderer's public term inventory.""" return { - subject - for _, term_type in renderer.TERM_TYPES - for subject in graph.subjects(RDF.type, term_type) - if isinstance(subject, URIRef) + ontology_subject + for _, term_type in ontology_renderer.TERM_TYPES + for ontology_subject in ontology_graph.subjects(RDF.type, term_type) + if isinstance(ontology_subject, URIRef) } -def validate_public_graph(graph: Graph, renderer: ModuleType) -> None: +def validate_public_graph( + ontology_graph: Graph, + ontology_renderer: ModuleType, +) -> None: """Reject renderer-visible RDF that cannot be published safely.""" - subjects = _public_subjects(graph, renderer) - fragment_owner: dict[str, URIRef] = {} - for subject in sorted(subjects, key=str): - fragment = public_fragment(_fragment(subject)) - owner = fragment_owner.setdefault(fragment, subject) - if owner != subject: + ontology_subjects = _public_ontology_subjects(ontology_graph, ontology_renderer) + fragment_owner_by_id: dict[str, URIRef] = {} + for ontology_subject in sorted(ontology_subjects, key=str): + public_fragment_id = public_fragment(_ontology_fragment(ontology_subject)) + previous_fragment_owner = fragment_owner_by_id.setdefault( + public_fragment_id, ontology_subject + ) + if previous_fragment_owner != ontology_subject: raise ValueError( - f"duplicate ontology fragment {fragment!r}: {owner} and {subject}" + f"duplicate ontology fragment {public_fragment_id!r}: " + f"{previous_fragment_owner} and {ontology_subject}" ) - for subject in subjects: - for predicate in (RDF.type, *(item[1] for item in renderer.RELATION_FIELDS)): - for value in graph.objects(subject, predicate): - if not isinstance(value, URIRef) or value in subjects: + for ontology_subject in ontology_subjects: + for relation_predicate in ( + RDF.type, + *( + relation_field[1] + for relation_field in ontology_renderer.RELATION_FIELDS + ), + ): + for linked_resource in ontology_graph.objects( + ontology_subject, relation_predicate + ): + if ( + not isinstance(linked_resource, URIRef) + or linked_resource in ontology_subjects + ): continue - scheme = urlsplit(str(value)).scheme.lower() - if scheme not in {"http", "https"}: + linked_iri_scheme = urlsplit(str(linked_resource)).scheme.lower() + if linked_iri_scheme not in {"http", "https"}: raise ValueError( - f"unsafe linked IRI scheme {scheme!r} for {value}" + f"unsafe linked IRI scheme {linked_iri_scheme!r} " + f"for {linked_resource}" ) -def _term_kind(graph: Graph, subject: URIRef) -> URIRef | None: +def _ontology_term_kind( + ontology_graph: Graph, + ontology_subject: URIRef, +) -> URIRef | None: """Return one supported RDF term kind, including entailed classes.""" - kinds = {kind for kind in _MAPPING_FOR_KIND if (subject, RDF.type, kind) in graph} - if any(graph.objects(subject, RDFS.subClassOf)): - kinds.add(OWL.Class) - return next(iter(kinds)) if len(kinds) == 1 else None + ontology_term_kinds = { + ontology_term_kind + for ontology_term_kind in _MAPPING_FOR_KIND + if (ontology_subject, RDF.type, ontology_term_kind) in ontology_graph + } + if any(ontology_graph.objects(ontology_subject, RDFS.subClassOf)): + ontology_term_kinds.add(OWL.Class) + return next(iter(ontology_term_kinds)) if len(ontology_term_kinds) == 1 else None def validate_compatibility_graph( - canonical: Graph, - compatibility: Graph, + canonical_ontology_graph: Graph, + compatibility_ontology_graph: Graph, ) -> None: """Reject namespace mappings whose local name or RDF term kind differs.""" - mappings = { - (subject, predicate, target) - for predicate in set(_MAPPING_FOR_KIND.values()) - for subject, target in compatibility.subject_objects(predicate) + namespace_mappings = { + (canonical_resource, mapping_predicate, deprecated_resource) + for mapping_predicate in set(_MAPPING_FOR_KIND.values()) + for canonical_resource, deprecated_resource in ( + compatibility_ontology_graph.subject_objects(mapping_predicate) + ) } - if not mappings: + if not namespace_mappings: raise ValueError("namespace compatibility vocabulary has no mappings") - for subject, predicate, target in mappings: - canonical_iri, deprecated_iri = str(subject), str(target) - if not canonical_iri.startswith(CANONICAL_NAMESPACE) or not deprecated_iri.startswith( - DEPRECATED_NAMESPACE - ): - raise ValueError("namespace compatibility mapping has an unexpected namespace") - if canonical_iri.removeprefix(CANONICAL_NAMESPACE) != deprecated_iri.removeprefix( - DEPRECATED_NAMESPACE - ): - raise ValueError("namespace compatibility mapping has different local names") - canonical_kind = _term_kind(canonical, subject) - deprecated_kind = _term_kind(compatibility, target) - if canonical_kind is None or canonical_kind != deprecated_kind: + for ( + canonical_resource, + mapping_predicate, + deprecated_resource, + ) in namespace_mappings: + canonical_iri = str(canonical_resource) + deprecated_iri = str(deprecated_resource) + if not canonical_iri.startswith( + CANONICAL_NAMESPACE + ) or not deprecated_iri.startswith(DEPRECATED_NAMESPACE): + raise ValueError( + "namespace compatibility mapping has an unexpected namespace" + ) + if canonical_iri.removeprefix( + CANONICAL_NAMESPACE + ) != deprecated_iri.removeprefix(DEPRECATED_NAMESPACE): + raise ValueError( + "namespace compatibility mapping has different local names" + ) + canonical_term_kind = _ontology_term_kind( + canonical_ontology_graph, canonical_resource + ) + deprecated_term_kind = _ontology_term_kind( + compatibility_ontology_graph, deprecated_resource + ) + if canonical_term_kind is None or canonical_term_kind != deprecated_term_kind: raise ValueError("namespace compatibility mapping has different term kinds") - if _MAPPING_FOR_KIND[canonical_kind] != predicate: + if _MAPPING_FOR_KIND[canonical_term_kind] != mapping_predicate: raise ValueError("namespace compatibility mapping uses the wrong predicate") -def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: +def validate_shapes_graph( + shacl_shapes_graph: Graph, + canonical_ontology_graph: Graph, +) -> None: """Reject SHACL shapes whose targets dangle outside the ontology. A shape that targets a class absent from the canonical graph, or @@ -153,123 +203,157 @@ def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: Only URI-valued targets and paths are checked; literal sh:path values are not part of this contract. """ - if not any(shapes.triples((None, RDF.type, SH.NodeShape))): + if not any(shacl_shapes_graph.triples((None, RDF.type, SH.NodeShape))): raise ValueError("SHACL shapes graph declares no sh:NodeShape") - for predicate in (SH.targetClass, SH.path): - for value in shapes.objects(None, predicate): + for shacl_predicate in (SH.targetClass, SH.path): + for linked_resource in shacl_shapes_graph.objects(None, shacl_predicate): if ( - not isinstance(value, URIRef) - or str(value).startswith(CANONICAL_NAMESPACE) - or (predicate == SH.path and value in STANDARD_SHACL_PATHS) + not isinstance(linked_resource, URIRef) + or str(linked_resource).startswith(CANONICAL_NAMESPACE) + or ( + shacl_predicate == SH.path + and linked_resource in STANDARD_SHACL_PATHS + ) ): continue - kind = "targetClass" if predicate == SH.targetClass else "path" + resource_kind = ( + "targetClass" if shacl_predicate == SH.targetClass else "path" + ) raise ValueError( - f"SHACL {kind} target outside the canonical namespace: {value}" + f"SHACL {resource_kind} target outside the canonical namespace: " + f"{linked_resource}" ) - declared_classes = { - subject - for subject in canonical.subjects(RDF.type, OWL.Class) - if isinstance(subject, URIRef) + declared_ontology_classes = { + ontology_subject + for ontology_subject in canonical_ontology_graph.subjects(RDF.type, OWL.Class) + if isinstance(ontology_subject, URIRef) } # Entailed classes: anything with a subclass assertion is a class. - declared_classes.update( - subject - for subject, _ in canonical.subject_objects(RDFS.subClassOf) - if isinstance(subject, URIRef) + declared_ontology_classes.update( + ontology_subject + for ontology_subject, _ in canonical_ontology_graph.subject_objects( + RDFS.subClassOf + ) + if isinstance(ontology_subject, URIRef) ) - declared_properties = { - subject - for subject in canonical.subjects(RDF.type, OWL.ObjectProperty) - if isinstance(subject, URIRef) + declared_ontology_properties = { + ontology_subject + for ontology_subject in canonical_ontology_graph.subjects( + RDF.type, OWL.ObjectProperty + ) + if isinstance(ontology_subject, URIRef) } - declared_properties.update( - subject - for subject in canonical.subjects(RDF.type, OWL.DatatypeProperty) - if isinstance(subject, URIRef) + declared_ontology_properties.update( + ontology_subject + for ontology_subject in canonical_ontology_graph.subjects( + RDF.type, OWL.DatatypeProperty + ) + if isinstance(ontology_subject, URIRef) ) - declared_properties.update(STANDARD_SHACL_PATHS) - for target in shapes.objects(None, SH.targetClass): - if target not in declared_classes: - raise ValueError(f"SHACL targetClass is not an ontology class: {target}") - for path in shapes.objects(None, SH.path): - if path not in declared_properties: - raise ValueError(f"SHACL property path is not an ontology property: {path}") + declared_ontology_properties.update(STANDARD_SHACL_PATHS) + for target_class in shacl_shapes_graph.objects(None, SH.targetClass): + if target_class not in declared_ontology_classes: + raise ValueError( + f"SHACL targetClass is not an ontology class: {target_class}" + ) + for property_path in shacl_shapes_graph.objects(None, SH.path): + if property_path not in declared_ontology_properties: + raise ValueError( + f"SHACL property path is not an ontology property: {property_path}" + ) -def _validate_output_directory(output_dir: Path, source: Path, profile: Path) -> Path: +def _validate_output_directory( + publication_output_dir: Path, + ontology_source_path: Path, + prov_profile_path: Path, +) -> Path: """Resolve an output path and ensure replacement cannot delete source data.""" - requested = output_dir.expanduser() - if requested.is_symlink(): + requested_output_path = publication_output_dir.expanduser() + if requested_output_path.is_symlink(): raise ValueError("output directory must not be a symbolic link") - output = requested.resolve() - if source.is_relative_to(output) or profile.is_relative_to(output): + resolved_output_path = requested_output_path.resolve() + if ontology_source_path.is_relative_to( + resolved_output_path + ) or prov_profile_path.is_relative_to(resolved_output_path): raise ValueError("output directory overlaps ontology source files") - if output.exists() and not (output / OUTPUT_MARKER).is_file(): + if ( + resolved_output_path.exists() + and not (resolved_output_path / OUTPUT_MARKER).is_file() + ): raise ValueError("refusing to replace an unmarked output directory") - return output + return resolved_output_path -def publish_site(repository_root: Path, output_dir: Path) -> None: +def publish_site(repository_root: Path, publication_output_dir: Path) -> None: """Validate sources and publish one safely replaceable static site tree.""" - root = repository_root.resolve() - source = root / SOURCE_RELATIVE_PATH - profile = root / PROV_PROFILE_RELATIVE_PATH - compatibility_source = root / COMPATIBILITY_RELATIVE_PATH - shapes_source = root / SHAPES_RELATIVE_PATH - if not source.is_file(): - raise FileNotFoundError(f"ontology source is missing: {source}") - if not profile.is_file(): - raise FileNotFoundError(f"PROV-O support profile is missing: {profile}") - if not compatibility_source.is_file(): + resolved_repository_root = repository_root.resolve() + ontology_source_path = resolved_repository_root / SOURCE_RELATIVE_PATH + prov_profile_path = resolved_repository_root / PROV_PROFILE_RELATIVE_PATH + compatibility_source_path = resolved_repository_root / COMPATIBILITY_RELATIVE_PATH + shapes_source_path = resolved_repository_root / SHAPES_RELATIVE_PATH + if not ontology_source_path.is_file(): + raise FileNotFoundError(f"ontology source is missing: {ontology_source_path}") + if not prov_profile_path.is_file(): + raise FileNotFoundError( + f"PROV-O support profile is missing: {prov_profile_path}" + ) + if not compatibility_source_path.is_file(): raise FileNotFoundError( - f"namespace compatibility vocabulary is missing: {compatibility_source}" + "namespace compatibility vocabulary is missing: " + f"{compatibility_source_path}" ) - if not shapes_source.is_file(): - raise FileNotFoundError(f"SHACL shapes graph is missing: {shapes_source}") - - output = _validate_output_directory(output_dir, source, profile) - renderer = _load_renderer(root) - graph = Graph().parse(source, format="turtle") - Graph().parse(profile, format="turtle") - compatibility_graph = Graph().parse(compatibility_source, format="turtle") - shapes_graph = Graph().parse(shapes_source, format="turtle") - validate_public_graph(graph, renderer) - validate_compatibility_graph(graph, compatibility_graph) - validate_shapes_graph(shapes_graph, graph) - - if output.exists(): - shutil.rmtree(output) + if not shapes_source_path.is_file(): + raise FileNotFoundError(f"SHACL shapes graph is missing: {shapes_source_path}") + + resolved_output_dir = _validate_output_directory( + publication_output_dir, ontology_source_path, prov_profile_path + ) + ontology_renderer = _load_renderer(resolved_repository_root) + canonical_ontology_graph = Graph().parse(ontology_source_path, format="turtle") + Graph().parse(prov_profile_path, format="turtle") + compatibility_ontology_graph = Graph().parse( + compatibility_source_path, format="turtle" + ) + shacl_shapes_graph = Graph().parse(shapes_source_path, format="turtle") + validate_public_graph(canonical_ontology_graph, ontology_renderer) + validate_compatibility_graph(canonical_ontology_graph, compatibility_ontology_graph) + validate_shapes_graph(shacl_shapes_graph, canonical_ontology_graph) + + if resolved_output_dir.exists(): + shutil.rmtree(resolved_output_dir) try: - renderer.build_site(root, output) + ontology_renderer.build_site(resolved_repository_root, resolved_output_dir) except BaseException: - shutil.rmtree(output, ignore_errors=True) + shutil.rmtree(resolved_output_dir, ignore_errors=True) raise - (output / OUTPUT_MARKER).write_text("", encoding="utf-8") + (resolved_output_dir / OUTPUT_MARKER).write_text("", encoding="utf-8") -def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: +def _parse_publication_arguments( + command_line_arguments: Iterable[str] | None = None, +) -> argparse.Namespace: """Parse repository and output paths for the publication command.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( + ontology_publication_parser = argparse.ArgumentParser(description=__doc__) + ontology_publication_parser.add_argument( "--repository-root", type=Path, default=Path(__file__).resolve().parents[1], help="LineageWeave repository root", ) - parser.add_argument( + ontology_publication_parser.add_argument( "--output-dir", type=Path, default=Path("_site"), help="Static site output directory", ) - return parser.parse_args(argv) + return ontology_publication_parser.parse_args(command_line_arguments) -def main(argv: Iterable[str] | None = None) -> int: +def main(command_line_arguments: Iterable[str] | None = None) -> int: """Publish the site from CLI arguments and return a process exit code.""" - args = _parse_args(argv) - publish_site(args.repository_root, args.output_dir) + command_arguments = _parse_publication_arguments(command_line_arguments) + publish_site(command_arguments.repository_root, command_arguments.output_dir) return 0 diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index 99b60d3e3..ecfa3a496 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -205,7 +205,7 @@ def test_compatibility_validation_is_term_kind_safe() -> None: ambiguous = Graph() ambiguous.add((post, RDF.type, OWL.Class)) ambiguous.add((post, RDF.type, OWL.ObjectProperty)) - assert publisher._term_kind(ambiguous, post) is None + assert publisher._ontology_term_kind(ambiguous, post) is None def test_shapes_validation_rejects_dangling_targets_and_outside_namespace() -> None: @@ -323,7 +323,10 @@ def test_main_publishes_site(tmp_path: Path) -> None: def test_loader_and_fragment_failure_branches(tmp_path: Path, monkeypatch) -> None: publisher = _load_publisher() - assert publisher._fragment(URIRef("https://example.test/vocabulary/Term")) == "Term" + assert ( + publisher._ontology_fragment(URIRef("https://example.test/vocabulary/Term")) + == "Term" + ) monkeypatch.setattr(publisher.importlib.util, "spec_from_file_location", lambda *_args: None) with pytest.raises(RuntimeError, match="could not be loaded"): publisher._load_renderer(tmp_path) From f68224ca9b54c1e3c44ce3c47e8228d13c6920d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:00:34 +0900 Subject: [PATCH 48/51] test(ontology): require semantic site-builder boundaries --- ...uild_ontology_site_semantic_identifiers.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_build_ontology_site_semantic_identifiers.py b/tests/test_build_ontology_site_semantic_identifiers.py index b4c0e0f05..35921ec42 100644 --- a/tests/test_build_ontology_site_semantic_identifiers.py +++ b/tests/test_build_ontology_site_semantic_identifiers.py @@ -5,6 +5,7 @@ import ast from pathlib import Path + SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "build_ontology_site.py" @@ -26,26 +27,55 @@ def test_ontology_site_builder_uses_semantic_owned_identifiers() -> None: assert owned_identifiers.isdisjoint( { "args", + "comment", + "counted", + "graph", + "heading", "item", "key", + "label", + "output", "parser", "payload", + "predicate", + "root", "rows", + "sections", + "source", + "subject", + "terms", "value", } ) assert { "command_arguments", + "documented_ontology_subjects", "json_item", "json_key", "json_value", "literal_value", "manifest_payload", + "ontology_graph", "ontology_resource", + "ontology_source_path", + "publication_output_dir", "relation_rows", + "repository_root_path", "site_build_parser", } <= owned_identifiers + owned_function_names = { + syntax_node.name + for syntax_node in ast.walk(syntax_tree) + if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + assert {"_fragment", "_parse_args", "_sha256"}.isdisjoint(owned_function_names) + assert { + "_file_sha256", + "_ontology_fragment", + "_parse_site_build_arguments", + } <= owned_function_names + def test_ontology_site_builder_preserves_publication_contracts() -> None: """Keep public URLs, CLI flags, formats, and manifest keys stable.""" From 4a81f8706ad819967a8a88787464174d59599002 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:04:24 +0900 Subject: [PATCH 49/51] refactor(ontology): name site-builder boundaries --- CHANGELOG.md | 5 +- docs/product-technical-gap-baseline.md | 26 +- scripts/build_ontology_site.py | 316 +++++++++++++++---------- tests/test_ontology_site.py | 58 +++-- 4 files changed, 247 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c11f5df..059de742e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -278,8 +278,9 @@ All notable changes to this project are documented here. Format follows JSON output keys, and resource-close behavior. - The deterministic ontology-site builder now uses semantic ontology-resource, - JSON-LD item, relation-row, manifest-payload, and CLI identifiers while - preserving public URLs, formats, manifest fields, generated bytes, and flags. + RDF-graph, source-path, publication-output, JSON-LD item, relation-row, + manifest-payload, digest, and CLI identifiers while preserving public URLs, + formats, manifest fields, generated bytes, and flags. - The queued LLM channel-weight estimator behavior tests now name their module boundary explicitly instead of using the generic `script` alias. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 29b71529a..2475611b2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,17 +42,21 @@ > boundary, compile, and lint validation GREEN locally; GitHub exact-head > checks and independent review pending. > -> Ontology-site builder naming overlay: 2026-09-08 KST. Protected `main` is -> `83eba56149eb802cd63642c507c324c9976ec78e`; exact RED head -> `fbde01a4696362d659db2f4b33f588882bdb8385` found repository-owned -> `value`, `key`, `item`, `rows`, `payload`, `parser`, and `args` identifiers -> across RDF rendering, JSON-LD canonicalization, manifest output, and the CLI. -> Action: align that complete builder surface with ontology-resource, -> serialization-item, relation-row, manifest-payload, and command language while -> preserving published URLs, CLI flags, RDF formats, manifest keys, generated -> bytes, and deterministic ordering. Status: RED reproduced; implementation, -> focused publication behavior, AST contract, compile, and scoped lint GREEN -> locally; GitHub exact-head checks and independent review pending. +> Ontology-site builder naming overlay: 2026-09-08 KST. The stacked base is +> `fix/contextual-orchestrator-owner-boundary@e5711282c48cc20d0a88fb56a9e382d500989c72`. +> Initial RED `fbde01a4696362d659db2f4b33f588882bdb8385` found repository-owned +> `value`, `key`, `item`, `rows`, `payload`, `parser`, and `args`; exact +> continuation RED `f68224ca9b54c1e3c44ce3c47e8228d13c6920d2` found `_sha256`, +> `_fragment`, `_parse_args`, `graph`, `subject`, `predicate`, `label`, +> `comment`, `root`, `source`, `output`, `terms`, `sections`, and `counted` +> across RDF rendering, JSON-LD canonicalization, manifest output, filesystem +> publication, and the CLI. Action: align the complete builder surface with +> ontology-resource, RDF-graph, source-path, publication-output, serialization, +> manifest, digest, and command language while preserving published URLs, CLI +> flags, RDF formats, manifest keys, generated bytes, and deterministic ordering. +> Status: both RED contracts reproduced; implementation, 32 focused +> publication/naming tests, compile, diff, and scoped Ruff/format GREEN locally; +> GitHub exact-head checks and independent review pending. > > Queued estimator test naming overlay: 2026-09-08 KST. Exact RED head > `e73e0a1afd66432022c81ef86e34fd15f20ee162` found the generic owned diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 08f5266ae..21200eb9f 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -67,12 +67,12 @@ ) -def _sha256(path: Path) -> str: +def _file_sha256(file_path: Path) -> str: """Return a lowercase SHA-256 digest for one file.""" - return hashlib.sha256(path.read_bytes()).hexdigest() + return hashlib.sha256(file_path.read_bytes()).hexdigest() -def _fragment(ontology_resource: URIRef) -> str: +def _ontology_fragment(ontology_resource: URIRef) -> str: """Return the stable local fragment used as the HTML anchor.""" ontology_iri = str(ontology_resource) if "#" in ontology_iri: @@ -80,12 +80,18 @@ def _fragment(ontology_resource: URIRef) -> str: return ontology_iri.rstrip("/").rsplit("/", 1)[-1] -def _preferred_literal(graph: Graph, subject: URIRef, predicate: URIRef) -> str | None: +def _preferred_literal( + ontology_graph: Graph, + ontology_subject: URIRef, + ontology_predicate: URIRef, +) -> str | None: """Choose an English, untagged, or first literal in a deterministic order.""" literals = sorted( ( literal_value - for literal_value in graph.objects(subject, predicate) + for literal_value in ontology_graph.objects( + ontology_subject, ontology_predicate + ) if isinstance(literal_value, Literal) ), key=lambda literal_value: ( @@ -126,14 +132,15 @@ def _canonicalize_json(json_value: Any, parent_key: str | None = None) -> Any: return json_value -def _write_serializations(graph: Graph, ontology_dir: Path) -> None: +def _write_serializations(ontology_graph: Graph, ontology_dir: Path) -> None: """Write deterministic JSON-LD and line-sorted N-Triples serializations.""" - canonical_graph = to_canonical_graph(graph) + canonical_graph = to_canonical_graph(ontology_graph) raw_jsonld = canonical_graph.serialize(format="json-ld", auto_compact=False) parsed_jsonld = json.loads(raw_jsonld) canonical_jsonld = _canonicalize_json(parsed_jsonld) (ontology_dir / "ontology.jsonld").write_text( - json.dumps(canonical_jsonld, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + json.dumps(canonical_jsonld, ensure_ascii=False, indent=2, sort_keys=True) + + "\n", encoding="utf-8", ) @@ -149,22 +156,29 @@ def _render_link(ontology_resource: URIRef, ontology_subjects: set[URIRef]) -> s """Render a local term link or a non-navigating external RDF identifier.""" if ontology_resource not in ontology_subjects: return f"{html.escape(str(ontology_resource))}" - href = html.escape(f"#{public_fragment(_fragment(ontology_resource))}", quote=True) - return f'{html.escape(_fragment(ontology_resource))}' + local_fragment_href = html.escape( + f"#{public_fragment(_ontology_fragment(ontology_resource))}", quote=True + ) + return ( + f'' + f"{html.escape(_ontology_fragment(ontology_resource))}" + ) def _render_relation_rows( - graph: Graph, - subject: URIRef, + ontology_graph: Graph, + ontology_subject: URIRef, ontology_subjects: set[URIRef], ) -> str: """Render standard semantic relations for one term.""" relation_rows: list[str] = [] - for heading, predicate in RELATION_FIELDS: + for relation_heading, relation_predicate in RELATION_FIELDS: relation_targets = sorted( ( ontology_resource - for ontology_resource in graph.objects(subject, predicate) + for ontology_resource in ontology_graph.objects( + ontology_subject, relation_predicate + ) if isinstance(ontology_resource, URIRef) ), key=str, @@ -176,36 +190,43 @@ def _render_relation_rows( for ontology_resource in relation_targets ) relation_rows.append( - f"

{html.escape(heading)}
{rendered_links}
" + f"
{html.escape(relation_heading)}
{rendered_links}
" ) return "".join(relation_rows) -def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: +def _render_term( + ontology_graph: Graph, + ontology_subject: URIRef, + ontology_subjects: set[URIRef], +) -> str: """Render one fragment-addressable ontology term section.""" - raw_fragment = _fragment(subject) + raw_fragment = _ontology_fragment(ontology_subject) fragment_href = public_fragment(raw_fragment) - label = ( - _preferred_literal(graph, subject, RDFS.label) - or _preferred_literal(graph, subject, SKOS.prefLabel) + term_label = ( + _preferred_literal(ontology_graph, ontology_subject, RDFS.label) + or _preferred_literal(ontology_graph, ontology_subject, SKOS.prefLabel) or raw_fragment ) - comment = _preferred_literal(graph, subject, SKOS.definition) or _preferred_literal( - graph, subject, RDFS.comment - ) + term_comment = _preferred_literal( + ontology_graph, ontology_subject, SKOS.definition + ) or _preferred_literal(ontology_graph, ontology_subject, RDFS.comment) lookup_predicate = URIRef(CANONICAL_LOOKUP_PREDICATE) lookup_codes = sorted( - str(lookup_value) for lookup_value in graph.objects(subject, lookup_predicate) + str(lookup_value) + for lookup_value in ontology_graph.objects(ontology_subject, lookup_predicate) ) type_values = sorted( ( type_value - for type_value in graph.objects(subject, RDF.type) + for type_value in ontology_graph.objects(ontology_subject, RDF.type) if isinstance(type_value, URIRef) ), key=str, ) - relation_rows = _render_relation_rows(graph, subject, ontology_subjects) + relation_rows = _render_relation_rows( + ontology_graph, ontology_subject, ontology_subjects + ) type_links = ", ".join( _render_link(type_value, ontology_subjects) for type_value in type_values ) @@ -217,22 +238,29 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) else "" ) fja_rows = "".join( - f"
{heading}
{html.escape(fja_value)}
" - for heading, predicate in ( + f"
{fja_heading}
{html.escape(fja_value)}
" + for fja_heading, fja_predicate in ( ("FJA domain", CANONICAL_FJA_DOMAIN_PREDICATE), ("FJA rank", CANONICAL_FJA_RANK_PREDICATE), ) - if (fja_value := _preferred_literal(graph, subject, predicate)) is not None + if ( + fja_value := _preferred_literal( + ontology_graph, ontology_subject, fja_predicate + ) + ) + is not None ) comment_html = ( - f'

{html.escape(comment)}

' if comment else "" + f'

{html.escape(term_comment)}

' + if term_comment + else "" ) return ( f'
' f'

# ' - f"{html.escape(label)}

" - f'

{html.escape(str(subject))}

' + f'aria-label="Link to {html.escape(term_label, quote=True)}"># ' + f"{html.escape(term_label)}" + f'

{html.escape(str(ontology_subject))}

' f"{comment_html}" '
' f"
RDF type
{type_links or 'Unspecified'}
" @@ -244,80 +272,95 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) ) -def _ontology_subjects(graph: Graph) -> set[URIRef]: +def _ontology_subjects(ontology_graph: Graph) -> set[URIRef]: """Return every URI subject that belongs in the generated term inventory.""" - subjects: set[URIRef] = set() + ontology_subjects: set[URIRef] = set() for _, rdf_type in TERM_TYPES: - subjects.update( - subject - for subject in graph.subjects(RDF.type, rdf_type) - if isinstance(subject, URIRef) + ontology_subjects.update( + ontology_subject + for ontology_subject in ontology_graph.subjects(RDF.type, rdf_type) + if isinstance(ontology_subject, URIRef) ) - return subjects + return ontology_subjects -def _render_term_sections(graph: Graph) -> tuple[str, str, int]: +def _render_term_sections(ontology_graph: Graph) -> tuple[str, str, int]: """Render the navigation and categorized term sections.""" - subjects = _ontology_subjects(graph) - nav_items: list[str] = [] - sections: list[str] = [] - counted: set[URIRef] = set() + ontology_subjects = _ontology_subjects(ontology_graph) + navigation_items: list[str] = [] + term_sections: list[str] = [] + documented_ontology_subjects: set[URIRef] = set() - for heading, rdf_type in TERM_TYPES: - terms = sorted( + for type_heading, rdf_type in TERM_TYPES: + ontology_terms = sorted( ( - subject - for subject in graph.subjects(RDF.type, rdf_type) - if isinstance(subject, URIRef) + ontology_subject + for ontology_subject in ontology_graph.subjects(RDF.type, rdf_type) + if isinstance(ontology_subject, URIRef) ), - key=lambda subject: ( + key=lambda ontology_subject: ( ( - _preferred_literal(graph, subject, RDFS.label) - or _preferred_literal(graph, subject, SKOS.prefLabel) - or _fragment(subject) + _preferred_literal(ontology_graph, ontology_subject, RDFS.label) + or _preferred_literal( + ontology_graph, ontology_subject, SKOS.prefLabel + ) + or _ontology_fragment(ontology_subject) ).casefold(), - str(subject), + str(ontology_subject), ), ) - terms = [term for term in terms if term not in counted] - if not terms: + ontology_terms = [ + ontology_term + for ontology_term in ontology_terms + if ontology_term not in documented_ontology_subjects + ] + if not ontology_terms: continue - section_id = heading.lower().replace(" ", "-") - nav_items.append( - f'
  • {html.escape(heading)} ' - f"{len(terms)}
  • " + section_id = type_heading.lower().replace(" ", "-") + navigation_items.append( + f'
  • {html.escape(type_heading)} ' + f"{len(ontology_terms)}
  • " ) - cards: list[str] = [] - for term in terms: - counted.add(term) - cards.append(_render_term(graph, term, subjects)) - sections.append( + term_cards: list[str] = [] + for ontology_term in ontology_terms: + documented_ontology_subjects.add(ontology_term) + term_cards.append( + _render_term(ontology_graph, ontology_term, ontology_subjects) + ) + term_sections.append( f'
    ' - f"

    {html.escape(heading)}

    " - '
    ' + "".join(cards) + "
    " + f"

    {html.escape(type_heading)}

    " + '
    ' + "".join(term_cards) + "
    " "
    " ) - return "".join(nav_items), "".join(sections), len(counted) + return ( + "".join(navigation_items), + "".join(term_sections), + len(documented_ontology_subjects), + ) -def _ontology_metadata(graph: Graph) -> tuple[str, str, str]: +def _ontology_metadata(ontology_graph: Graph) -> tuple[str, str, str]: """Return ontology IRI, label, and comment from the source graph.""" ontology_nodes = sorted( ( - subject - for subject in graph.subjects(RDF.type, OWL.Ontology) - if isinstance(subject, URIRef) + ontology_subject + for ontology_subject in ontology_graph.subjects(RDF.type, OWL.Ontology) + if isinstance(ontology_subject, URIRef) ), key=str, ) if not ontology_nodes: raise ValueError("source graph does not declare an owl:Ontology resource") - subject = ontology_nodes[0] - label = _preferred_literal(graph, subject, RDFS.label) or "LineageWeave ontology" - comment = _preferred_literal(graph, subject, RDFS.comment) or ( - "Formal OWL 2, RDF Schema, and SKOS vocabulary for LineageWeave." + ontology_subject = ontology_nodes[0] + ontology_label = ( + _preferred_literal(ontology_graph, ontology_subject, RDFS.label) + or "LineageWeave ontology" ) - return str(subject), label, comment + ontology_comment = _preferred_literal( + ontology_graph, ontology_subject, RDFS.comment + ) or ("Formal OWL 2, RDF Schema, and SKOS vocabulary for LineageWeave.") + return str(ontology_subject), ontology_label, ontology_comment def _style_sheet() -> str: @@ -371,17 +414,19 @@ def _style_sheet() -> str: """.strip() -def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: +def _render_ontology_page(ontology_graph: Graph, source_sha256: str) -> tuple[str, int]: """Render the complete ontology documentation page and unique term count.""" - ontology_iri, label, comment = _ontology_metadata(graph) - nav, term_sections, term_count = _render_term_sections(graph) + ontology_iri, ontology_label, ontology_comment = _ontology_metadata(ontology_graph) + category_navigation, term_sections, term_count = _render_term_sections( + ontology_graph + ) return ( "\n" '\n\n' '\n' '\n' - f"{html.escape(label)}\n" - f'\n' + f"{html.escape(ontology_label)}\n" + f'\n' f' ' f"{CANONICAL_LINK_SUPPRESSION}\n" '\n' @@ -391,9 +436,9 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: "\n\n" "
    " '

    LineageWeave / Ontology

    ' - f"

    {html.escape(label)}

    " - f"

    {html.escape(comment)}

    " - f'

    Ontology IRI: {html.escape(ontology_iri)}

    ' + f"

    {html.escape(ontology_label)}

    " + f"

    {html.escape(ontology_comment)}

    " + f"

    Ontology IRI: {html.escape(ontology_iri)}

    " "
    " "
    " '
    ' @@ -409,15 +454,15 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: "
    " '
    ' f'
    {term_count}
    Unique documented terms
    ' - f'
    {len(graph)}
    RDF triples
    ' + f'
    {len(ontology_graph)}
    RDF triples
    ' f'
    {html.escape(source_sha256[:12])}
    Source SHA-256 prefix
    ' "
    " '

    Identity boundary: this project page is the stable documentation endpoint requested for the repository. Per ADR 0207 the repository-case ontology IRI shown above is the canonical semantic identifier; the lowercase namespace remains a deprecated compatibility vocabulary with validated mappings.

    ' '" + f"{category_navigation}" f"{term_sections}" "
    " - '

    Generated deterministically from docs/ontology/lineageweave-kg.ttl. No analytics or external scripts.

    ' + "

    Generated deterministically from docs/ontology/lineageweave-kg.ttl. No analytics or external scripts.

    " "\n\n", term_count, ) @@ -435,7 +480,7 @@ def _render_root_page() -> str: f"" "

    LineageWeave public specifications

    " "

    Stable, machine-readable public artifacts published from the protected repository source.

    " - '

    Ontology

    Inspect the OWL 2, RDF Schema, SKOS, and provenance vocabulary.

    ' + "

    Ontology

    Inspect the OWL 2, RDF Schema, SKOS, and provenance vocabulary.

    " '

    Open the ontology documentation

    ' "

    ContextualWisdomLab / LineageWeave

    " "\n" @@ -444,8 +489,8 @@ def _render_root_page() -> str: def _write_manifest( ontology_dir: Path, - source: Path, - graph: Graph, + ontology_source_path: Path, + ontology_graph: Graph, term_count: int, ) -> None: """Write deterministic provenance metadata for the published ontology.""" @@ -462,60 +507,73 @@ def _write_manifest( "prov-o-support-profile.ttl", ], "shapes_path": SHAPES_RELATIVE_PATH.as_posix(), - "ontology_triple_count": len(graph), + "ontology_triple_count": len(ontology_graph), "ontology_unique_term_count": term_count, "source_path": SOURCE_RELATIVE_PATH.as_posix(), - "source_sha256": _sha256(source), + "source_sha256": _file_sha256(ontology_source_path), } (ontology_dir / "manifest.json").write_text( - json.dumps(manifest_payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + json.dumps(manifest_payload, ensure_ascii=False, indent=2, sort_keys=True) + + "\n", encoding="utf-8", ) def build_site(repository_root: Path, output_dir: Path) -> None: """Build the complete static ontology site under ``output_dir``.""" - root = repository_root.resolve() - output = output_dir.resolve() - source = root / SOURCE_RELATIVE_PATH - prov_profile = root / PROV_PROFILE_RELATIVE_PATH - compatibility = root / COMPATIBILITY_RELATIVE_PATH - shapes = root / SHAPES_RELATIVE_PATH - if not source.is_file(): - raise FileNotFoundError(f"ontology source is missing: {source}") - if not prov_profile.is_file(): - raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") - if not compatibility.is_file(): - raise FileNotFoundError(f"namespace compatibility vocabulary is missing: {compatibility}") - if not shapes.is_file(): - raise FileNotFoundError(f"SHACL shapes graph is missing: {shapes}") - - if output.exists(): + repository_root_path = repository_root.resolve() + publication_output_dir = output_dir.resolve() + ontology_source_path = repository_root_path / SOURCE_RELATIVE_PATH + provenance_profile_path = repository_root_path / PROV_PROFILE_RELATIVE_PATH + namespace_compatibility_path = repository_root_path / COMPATIBILITY_RELATIVE_PATH + shacl_shapes_path = repository_root_path / SHAPES_RELATIVE_PATH + if not ontology_source_path.is_file(): + raise FileNotFoundError(f"ontology source is missing: {ontology_source_path}") + if not provenance_profile_path.is_file(): + raise FileNotFoundError( + f"PROV-O support profile is missing: {provenance_profile_path}" + ) + if not namespace_compatibility_path.is_file(): + raise FileNotFoundError( + "namespace compatibility vocabulary is missing: " + f"{namespace_compatibility_path}" + ) + if not shacl_shapes_path.is_file(): + raise FileNotFoundError(f"SHACL shapes graph is missing: {shacl_shapes_path}") + + if publication_output_dir.exists(): raise FileExistsError( "refusing to replace an existing output directory; " "use publish_ontology_site for marked replacement" ) - ontology_dir = output / "ontology" + ontology_dir = publication_output_dir / "ontology" ontology_dir.mkdir(parents=True) - graph = Graph().parse(source, format="turtle") - source_sha256 = _sha256(source) - ontology_html, term_count = _render_ontology_page(graph, source_sha256) + ontology_graph = Graph().parse(ontology_source_path, format="turtle") + source_sha256 = _file_sha256(ontology_source_path) + ontology_html, term_count = _render_ontology_page(ontology_graph, source_sha256) - (output / ".nojekyll").write_text("", encoding="utf-8") - (output / "index.html").write_text(_render_root_page(), encoding="utf-8") + (publication_output_dir / ".nojekyll").write_text("", encoding="utf-8") + (publication_output_dir / "index.html").write_text( + _render_root_page(), encoding="utf-8" + ) (ontology_dir / "index.html").write_text(ontology_html, encoding="utf-8") - shutil.copyfile(source, ontology_dir / "ontology.ttl") - shutil.copyfile(prov_profile, ontology_dir / "prov-o-support-profile.ttl") - shutil.copyfile(compatibility, ontology_dir / "namespace-compatibility.ttl") - shutil.copyfile(shapes, ontology_dir / "lineageweave-kg-shapes.ttl") - _write_serializations(graph, ontology_dir) - _write_manifest(ontology_dir, source, graph, term_count) - (output / "robots.txt").write_text( - "User-agent: *\nAllow: /\nSitemap: " f"{PUBLIC_BASE_URL}/sitemap.xml\n", + shutil.copyfile(ontology_source_path, ontology_dir / "ontology.ttl") + shutil.copyfile( + provenance_profile_path, ontology_dir / "prov-o-support-profile.ttl" + ) + shutil.copyfile( + namespace_compatibility_path, + ontology_dir / "namespace-compatibility.ttl", + ) + shutil.copyfile(shacl_shapes_path, ontology_dir / "lineageweave-kg-shapes.ttl") + _write_serializations(ontology_graph, ontology_dir) + _write_manifest(ontology_dir, ontology_source_path, ontology_graph, term_count) + (publication_output_dir / "robots.txt").write_text( + f"User-agent: *\nAllow: /\nSitemap: {PUBLIC_BASE_URL}/sitemap.xml\n", encoding="utf-8", ) - (output / "sitemap.xml").write_text( + (publication_output_dir / "sitemap.xml").write_text( '\n' '\n' f" {PUBLIC_BASE_URL}/\n" @@ -525,7 +583,9 @@ def build_site(repository_root: Path, output_dir: Path) -> None: ) -def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: +def _parse_site_build_arguments( + argv: Iterable[str] | None = None, +) -> argparse.Namespace: """Parse command-line arguments for repository and output locations.""" site_build_parser = argparse.ArgumentParser(description=__doc__) site_build_parser.add_argument( @@ -545,7 +605,7 @@ def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: def main(argv: Iterable[str] | None = None) -> int: """Build the site from CLI arguments and return a process exit code.""" - command_arguments = _parse_args(argv) + command_arguments = _parse_site_build_arguments(argv) build_site(command_arguments.repository_root, command_arguments.output_dir) return 0 diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index fcd0d95f3..d128a9d1f 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -42,7 +42,10 @@ def block_package_import(name, globals_=None, locals_=None, fromlist=(), level=0 assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - assert module.public_fragment("Safety/한국어 term") == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term" + assert ( + module.public_fragment("Safety/한국어 term") + == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term" + ) def _tree_hashes(root: Path) -> dict[str, str]: @@ -53,7 +56,9 @@ def _tree_hashes(root: Path) -> dict[str, str]: } -def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path) -> None: +def test_build_publishes_dereferenceable_html_and_machine_formats( + tmp_path: Path, +) -> None: builder = _load_builder() output = tmp_path / "site" @@ -75,7 +80,10 @@ def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path ).read_bytes() html = (ontology_dir / "index.html").read_text(encoding="utf-8") - assert '' in html + assert ( + '' + in html + ) assert "canonical metadata fetches no subresource" in html assert 'id="Post"' in html assert 'href="#Post"' in html @@ -91,12 +99,8 @@ def test_render_term_escapes_untrusted_ontology_text() -> None: graph = Graph() term = builder.URIRef("https://example.test/ontology#Unsafe") graph.add((term, builder.RDF.type, builder.OWL.Class)) - graph.add( - (term, builder.RDFS.label, builder.Literal("")) - ) - graph.add( - (term, builder.RDFS.comment, builder.Literal("A & evidence.")) - ) + graph.add((term, builder.RDFS.label, builder.Literal(""))) + graph.add((term, builder.RDFS.comment, builder.Literal("A & evidence."))) rendered = builder._render_term(graph, term, {term}) @@ -105,7 +109,9 @@ def test_render_term_escapes_untrusted_ontology_text() -> None: assert "A <source> & evidence." in rendered -def test_render_term_omits_missing_lookup_code_and_does_not_link_external_iris() -> None: +def test_render_term_omits_missing_lookup_code_and_does_not_link_external_iris() -> ( + None +): builder = _load_builder() graph = Graph() term = builder.URIRef("https://example.test/ontology#Term") @@ -178,7 +184,9 @@ def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: output = tmp_path / "site" builder.build_site(ROOT, output) - source = Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle") + source = Graph().parse( + ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle" + ) jsonld = Graph() to_rdf(json.loads((output / "ontology" / "ontology.jsonld").read_text()), jsonld) ntriples = Graph().parse(output / "ontology" / "ontology.nt", format="nt") @@ -210,11 +218,16 @@ def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) output = tmp_path / "site" builder.build_site(ROOT, output) - manifest = json.loads((output / "ontology" / "manifest.json").read_text(encoding="utf-8")) + manifest = json.loads( + (output / "ontology" / "manifest.json").read_text(encoding="utf-8") + ) source = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" assert manifest["source_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() assert "built_at" not in manifest - assert manifest["documentation_url"] == "https://contextualwisdomlab.github.io/LineageWeave/ontology" + assert ( + manifest["documentation_url"] + == "https://contextualwisdomlab.github.io/LineageWeave/ontology" + ) assert manifest["generated_artifacts"] == [ "index.html", "lineageweave-kg-shapes.ttl", @@ -230,7 +243,12 @@ def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None: builder = _load_builder() - assert builder._fragment(builder.URIRef("https://example.test/vocabulary/Term")) == "Term" + assert ( + builder._ontology_fragment( + builder.URIRef("https://example.test/vocabulary/Term") + ) + == "Term" + ) assert builder._canonicalize_json({"@list": ["b", "a"]}) == {"@list": ["b", "a"]} graph = Graph() graph.add( @@ -266,7 +284,9 @@ def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None: assert term_count == 1 -def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tmp_path: Path) -> None: +def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output( + tmp_path: Path, +) -> None: builder = _load_builder() repository = tmp_path / "repository" output = tmp_path / "site" @@ -283,7 +303,9 @@ def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tm ontology_dir = repository / "docs" / "ontology" ontology_dir.mkdir(parents=True) (ontology_dir / "lineageweave-kg.ttl").write_text( - (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_text(encoding="utf-8"), + (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_text( + encoding="utf-8" + ), encoding="utf-8", ) try: @@ -332,7 +354,9 @@ def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tm def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None: builder = _load_builder() output = tmp_path / "direct" - assert builder.main(["--repository-root", str(ROOT), "--output-dir", str(output)]) == 0 + assert ( + builder.main(["--repository-root", str(ROOT), "--output-dir", str(output)]) == 0 + ) assert (output / "ontology" / "index.html").is_file() import runpy From 4770efa31acdb7996b945135541e34d59dcd953c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:27:56 +0900 Subject: [PATCH 50/51] test(import): require job-architecture identifiers --- ...t_job_architecture_semantic_identifiers.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_import_job_architecture_semantic_identifiers.py diff --git a/tests/test_import_job_architecture_semantic_identifiers.py b/tests/test_import_job_architecture_semantic_identifiers.py new file mode 100644 index 000000000..86e45cd2a --- /dev/null +++ b/tests/test_import_job_architecture_semantic_identifiers.py @@ -0,0 +1,72 @@ +"""Semantic identifier contracts for the authorized job-architecture importer.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +_GENERIC_OWNED_NAMES = { + "args", + "bindings", + "child", + "children", + "code", + "conn", + "count", + "digest", + "edge", + "edges", + "entity_id", + "field", + "incoming", + "key", + "kind", + "missing", + "name", + "node", + "nodes", + "occupation", + "parent", + "parsed", + "parser", + "path", + "ready", + "reader", + "row", + "scheme", + "supplied", + "text", + "value", + "version", + "visited", +} + + +def _bound_names(source_tree: ast.AST) -> set[str]: + """Collect function, argument, assignment, and loop-target names.""" + bound_names: set[str] = set() + for syntax_node in ast.walk(source_tree): + if isinstance(syntax_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + bound_names.add(syntax_node.name) + bound_names.update( + argument.arg + for argument in ( + *syntax_node.args.posonlyargs, + *syntax_node.args.args, + *syntax_node.args.kwonlyargs, + ) + ) + elif isinstance(syntax_node, ast.Name) and isinstance( + syntax_node.ctx, (ast.Store, ast.Param) + ): + bound_names.add(syntax_node.id) + return bound_names + + +def test_job_architecture_importer_uses_bounded_context_names() -> None: + """Reject underspecified owned identifiers while preserving source contracts.""" + source_file = Path(__file__).parents[1] / "scripts" / "import_job_architecture.py" + source_tree = ast.parse(source_file.read_text(encoding="utf-8")) + + assert _GENERIC_OWNED_NAMES.isdisjoint(_bound_names(source_tree)) From 156831f14e1302d1361bcdb7cd4674ef49924204 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:30:10 +0900 Subject: [PATCH 51/51] refactor(import): name job-architecture boundaries --- CHANGELOG.md | 6 + docs/product-technical-gap-baseline.md | 16 + scripts/import_job_architecture.py | 433 +++++++++++------- tests/test_import_job_architecture.py | 132 ++++-- ...t_job_architecture_semantic_identifiers.py | 6 +- 5 files changed, 390 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 059de742e..df6981697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,12 @@ All notable changes to this project are documented here. Format follows ### Changed +- The authorized job-architecture importer and its behavioral fixture now use + semantic source-snapshot, job-architecture node, hierarchy-edge, + occupation-binding, database, digest, and command identifiers while + preserving CSV columns, CLI flags, SQL/schema, aggregate JSON fields, + transaction behavior, and connection close. + - The governed ontology-site publisher now uses semantic renderer, ontology graph, namespace-mapping, SHACL-resource, source-path, output-path, and CLI identifiers while preserving public function signatures, CLI flags, RDF diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2475611b2..dcbfdb6be 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,21 @@ # Product & Technical Gap Baseline +> Authorized job-architecture import naming overlay: 2026-09-08 KST. The +> stacked base is exact head +> `4a81f8706ad819967a8a88787464174d59599002`; exact RED head +> `4770efa31acdb7996b945135541e34d59dcd953c` found repository-owned +> `code`, `kind`, `name`, `path`, `row`, `node`, `edge`, `binding`, +> `parser`, `args`, `conn`, `entity_id`, `key`, `digest`, and traversal +> accumulator identifiers across authorized CSV parsing, hierarchy validation, +> occupation binding, transactional persistence, and behavioral fixtures. +> Action: align the complete private caller and owned field surface with +> source-snapshot, job-architecture node, hierarchy-edge, occupation-binding, +> corporate-entity, database, digest, and command language while preserving +> external CSV columns, CLI flags, SQL/schema, source evidence, aggregate JSON +> fields, transaction behavior, and connection close. Status: RED reproduced; +> six focused behavior/naming tests, compile, and scoped Ruff/format GREEN +> locally; GitHub exact-head checks and independent review pending. +> > Ontology-site publisher naming overlay: 2026-09-08 KST. The stacked base is > `fix/contextual-orchestrator-owner-boundary@e5711282c48cc20d0a88fb56a9e382d500989c72`; > exact RED head `1e077883e807f2e42f0b61743d99815469460af6` found diff --git a/scripts/import_job_architecture.py b/scripts/import_job_architecture.py index 5fb436dfd..ec1578ba8 100644 --- a/scripts/import_job_architecture.py +++ b/scripts/import_job_architecture.py @@ -15,7 +15,7 @@ import asyncpg -_FIELDS = { +_SOURCE_COLUMN_NAMES = { "Node Code", "Node Kind", "Node Name", @@ -28,19 +28,19 @@ "Occupation Code", "Occupation Relation", } -_KINDS = {"job_family", "job_series"} -_SOURCE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,62}$") -_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") +_JOB_ARCHITECTURE_KIND_CODES = {"job_family", "job_series"} +_SOURCE_SYSTEM_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$") @dataclass(frozen=True) class JobArchitectureNode: """One exact node from an authorized source snapshot.""" - code: str - kind: str - name: str - description: str | None + job_architecture_code: str + job_architecture_kind_code: str + job_architecture_name: str + job_architecture_description: str | None valid_from: date | None valid_to: date | None @@ -58,39 +58,39 @@ class JobArchitectureEdge: class OccupationBinding: """One explicit source binding to an external occupation code.""" - node_code: str - scheme_iri: str - scheme_version: str + job_architecture_code: str + occupation_scheme_iri: str + occupation_scheme_version: str occupation_code: str source_relation_code: str -def _optional_date(value: str, field: str) -> date | None: +def _optional_date(source_date_text: str, field_label: str) -> date | None: """Parse an optional ISO date without inventing a missing instant.""" - text = value.strip() - if not text: + normalized_date_text = source_date_text.strip() + if not normalized_date_text: return None try: - return date.fromisoformat(text) + return date.fromisoformat(normalized_date_text) except ValueError as exc: - raise ValueError(f"invalid {field}: {value!r}") from exc + raise ValueError(f"invalid {field_label}: {source_date_text!r}") from exc -def _https_url(value: str, field: str) -> str: +def _https_url(source_url: str, field_label: str) -> str: """Validate an HTTPS URL with no embedded credentials.""" - parsed = urlsplit(value) + parsed_url = urlsplit(source_url) if ( - parsed.scheme != "https" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None + parsed_url.scheme != "https" + or not parsed_url.hostname + or parsed_url.username is not None + or parsed_url.password is not None ): - raise ValueError(f"{field} must be an HTTPS URL without userinfo") - return value + raise ValueError(f"{field_label} must be an HTTPS URL without userinfo") + return source_url def read_job_architecture( - path: Path, + source_file_path: Path, ) -> tuple[ list[JobArchitectureNode], list[JobArchitectureEdge], @@ -98,158 +98,233 @@ def read_job_architecture( int, ]: """Return exact nodes, hierarchy edges, and explicit occupation bindings.""" - nodes: dict[str, JobArchitectureNode] = {} - edges: dict[tuple[str, str], JobArchitectureEdge] = {} - bindings: dict[tuple[str, str, str, str], OccupationBinding] = {} - row_count = 0 - with path.open(encoding="utf-8-sig", newline="") as handle: - reader = csv.DictReader(handle) - missing = sorted(_FIELDS - set(reader.fieldnames or ())) - if missing: - raise ValueError(f"missing CSV columns: {', '.join(missing)}") - for line_number, row in enumerate(reader, start=2): - row_count += 1 - if None in row or any(value is None for value in row.values()): + job_architecture_nodes_by_code: dict[str, JobArchitectureNode] = {} + hierarchy_edges_by_codes: dict[tuple[str, str], JobArchitectureEdge] = {} + occupation_bindings_by_identity: dict[ + tuple[str, str, str, str], OccupationBinding + ] = {} + source_row_count = 0 + with source_file_path.open(encoding="utf-8-sig", newline="") as source_file: + source_row_reader = csv.DictReader(source_file) + missing_columns = sorted( + _SOURCE_COLUMN_NAMES - set(source_row_reader.fieldnames or ()) + ) + if missing_columns: + raise ValueError(f"missing CSV columns: {', '.join(missing_columns)}") + for line_number, source_row in enumerate(source_row_reader, start=2): + source_row_count += 1 + if None in source_row or any( + column_value is None for column_value in source_row.values() + ): raise ValueError(f"malformed CSV row: {line_number}") - code = row["Node Code"].strip() - kind = row["Node Kind"].strip() - name = row["Node Name"].strip() - if not code or not name or kind not in _KINDS: + job_architecture_code = source_row["Node Code"].strip() + job_architecture_kind_code = source_row["Node Kind"].strip() + job_architecture_name = source_row["Node Name"].strip() + if ( + not job_architecture_code + or not job_architecture_name + or job_architecture_kind_code not in _JOB_ARCHITECTURE_KIND_CODES + ): raise ValueError(f"invalid node identity at row {line_number}") - valid_from = _optional_date(row["Valid From"], "valid from") - valid_to = _optional_date(row["Valid To"], "valid to") + valid_from = _optional_date(source_row["Valid From"], "valid from") + valid_to = _optional_date(source_row["Valid To"], "valid to") if valid_from and valid_to and valid_from > valid_to: raise ValueError(f"inverted validity interval at row {line_number}") - node = JobArchitectureNode( - code, - kind, - name, - row.get("Description", "").strip() or None, + job_architecture_node = JobArchitectureNode( + job_architecture_code, + job_architecture_kind_code, + job_architecture_name, + source_row.get("Description", "").strip() or None, valid_from, valid_to, ) - if code in nodes and nodes[code] != node: - raise ValueError(f"conflicting node identity: {code}") - nodes[code] = node - parent = row["Parent Code"].strip() - hierarchy_relation = row["Hierarchy Relation"].strip() - if parent: - if not hierarchy_relation: + if ( + job_architecture_code in job_architecture_nodes_by_code + and job_architecture_nodes_by_code[job_architecture_code] + != job_architecture_node + ): + raise ValueError(f"conflicting node identity: {job_architecture_code}") + job_architecture_nodes_by_code[job_architecture_code] = ( + job_architecture_node + ) + broader_job_architecture_code = source_row["Parent Code"].strip() + hierarchy_relation_code = source_row["Hierarchy Relation"].strip() + if broader_job_architecture_code: + if not hierarchy_relation_code: raise ValueError(f"missing hierarchy relation at row {line_number}") - edge = JobArchitectureEdge(parent, code, hierarchy_relation) - edge_key = (parent, code) - if edge_key in edges and edges[edge_key] != edge: - raise ValueError(f"conflicting hierarchy relation at row {line_number}") - edges[edge_key] = edge - scheme = row["Occupation Scheme IRI"].strip() - version = row["Occupation Scheme Version"].strip() - occupation = row["Occupation Code"].strip() - occupation_relation = row["Occupation Relation"].strip() - supplied = ( - bool(scheme), - bool(version), - bool(occupation), - bool(occupation_relation), + hierarchy_edge = JobArchitectureEdge( + broader_job_architecture_code, + job_architecture_code, + hierarchy_relation_code, + ) + hierarchy_edge_identity = ( + broader_job_architecture_code, + job_architecture_code, + ) + if ( + hierarchy_edge_identity in hierarchy_edges_by_codes + and hierarchy_edges_by_codes[hierarchy_edge_identity] + != hierarchy_edge + ): + raise ValueError( + f"conflicting hierarchy relation at row {line_number}" + ) + hierarchy_edges_by_codes[hierarchy_edge_identity] = hierarchy_edge + occupation_scheme_iri = source_row["Occupation Scheme IRI"].strip() + occupation_scheme_version = source_row["Occupation Scheme Version"].strip() + occupation_code = source_row["Occupation Code"].strip() + occupation_relation_code = source_row["Occupation Relation"].strip() + occupation_binding_presence = ( + bool(occupation_scheme_iri), + bool(occupation_scheme_version), + bool(occupation_code), + bool(occupation_relation_code), ) - if any(supplied) and not all(supplied): + if any(occupation_binding_presence) and not all( + occupation_binding_presence + ): raise ValueError(f"partial occupation binding at row {line_number}") - if all(supplied): - parsed = urlsplit(scheme) + if all(occupation_binding_presence): + parsed_scheme_iri = urlsplit(occupation_scheme_iri) if ( - parsed.scheme not in {"http", "https"} - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None + parsed_scheme_iri.scheme not in {"http", "https"} + or not parsed_scheme_iri.hostname + or parsed_scheme_iri.username is not None + or parsed_scheme_iri.password is not None ): - raise ValueError(f"invalid occupation scheme IRI at row {line_number}") - if not occupation_relation: + raise ValueError( + f"invalid occupation scheme IRI at row {line_number}" + ) + if not occupation_relation_code: raise ValueError(f"missing binding relation at row {line_number}") - binding = OccupationBinding( - code, - scheme, - version, - occupation, - occupation_relation, + occupation_binding = OccupationBinding( + job_architecture_code, + occupation_scheme_iri, + occupation_scheme_version, + occupation_code, + occupation_relation_code, + ) + occupation_binding_identity = ( + job_architecture_code, + occupation_scheme_iri, + occupation_scheme_version, + occupation_code, ) - binding_key = (code, scheme, version, occupation) - if binding_key in bindings and bindings[binding_key] != binding: - raise ValueError(f"conflicting occupation relation at row {line_number}") - bindings[binding_key] = binding - if not nodes: + if ( + occupation_binding_identity in occupation_bindings_by_identity + and occupation_bindings_by_identity[occupation_binding_identity] + != occupation_binding + ): + raise ValueError( + f"conflicting occupation relation at row {line_number}" + ) + occupation_bindings_by_identity[occupation_binding_identity] = ( + occupation_binding + ) + if not job_architecture_nodes_by_code: raise ValueError("job architecture file has no rows") - for edge in edges.values(): - if edge.broader_code not in nodes: - raise ValueError(f"unknown parent node: {edge.broader_code}") - if edge.broader_code == edge.narrower_code: - raise ValueError(f"self hierarchy edge: {edge.broader_code}") - children: dict[str, set[str]] = {code: set() for code in nodes} - incoming = dict.fromkeys(nodes, 0) - for edge in edges.values(): - children[edge.broader_code].add(edge.narrower_code) - incoming[edge.narrower_code] += 1 - ready = [code for code, count in incoming.items() if count == 0] - visited = 0 - while ready: - code = ready.pop() - visited += 1 - for child in children[code]: - incoming[child] -= 1 - if incoming[child] == 0: - ready.append(child) - if visited != len(nodes): + for hierarchy_edge in hierarchy_edges_by_codes.values(): + if hierarchy_edge.broader_code not in job_architecture_nodes_by_code: + raise ValueError(f"unknown parent node: {hierarchy_edge.broader_code}") + if hierarchy_edge.broader_code == hierarchy_edge.narrower_code: + raise ValueError(f"self hierarchy edge: {hierarchy_edge.broader_code}") + child_codes_by_parent: dict[str, set[str]] = { + job_architecture_code: set() + for job_architecture_code in job_architecture_nodes_by_code + } + incoming_edge_count_by_code = dict.fromkeys(job_architecture_nodes_by_code, 0) + for hierarchy_edge in hierarchy_edges_by_codes.values(): + child_codes_by_parent[hierarchy_edge.broader_code].add( + hierarchy_edge.narrower_code + ) + incoming_edge_count_by_code[hierarchy_edge.narrower_code] += 1 + ready_node_codes = [ + job_architecture_code + for job_architecture_code, incoming_edge_count in incoming_edge_count_by_code.items() + if incoming_edge_count == 0 + ] + visited_node_count = 0 + while ready_node_codes: + job_architecture_code = ready_node_codes.pop() + visited_node_count += 1 + for child_node_code in child_codes_by_parent[job_architecture_code]: + incoming_edge_count_by_code[child_node_code] -= 1 + if incoming_edge_count_by_code[child_node_code] == 0: + ready_node_codes.append(child_node_code) + if visited_node_count != len(job_architecture_nodes_by_code): raise ValueError("cyclic job architecture hierarchy") return ( - list(nodes.values()), - sorted(edges.values(), key=repr), - sorted(bindings.values(), key=repr), - row_count, + list(job_architecture_nodes_by_code.values()), + sorted(hierarchy_edges_by_codes.values(), key=repr), + sorted(occupation_bindings_by_identity.values(), key=repr), + source_row_count, ) -def _parser() -> argparse.ArgumentParser: +def _job_architecture_import_parser() -> argparse.ArgumentParser: """Build the explicit source-snapshot import contract.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--target-dsn", required=True) - parser.add_argument("--corporate-entity-code", required=True) - parser.add_argument("--source-system-code", required=True) - parser.add_argument("--source-snapshot-code", required=True) - parser.add_argument("--source-name", required=True) - parser.add_argument("--source-url", required=True) - parser.add_argument("--source-sha256", required=True) - parser.add_argument("--source-row-count", type=int, required=True) - parser.add_argument("--source-file", type=Path, required=True) - return parser + import_parser = argparse.ArgumentParser(description=__doc__) + import_parser.add_argument("--target-dsn", required=True) + import_parser.add_argument("--corporate-entity-code", required=True) + import_parser.add_argument("--source-system-code", required=True) + import_parser.add_argument("--source-snapshot-code", required=True) + import_parser.add_argument("--source-name", required=True) + import_parser.add_argument("--source-url", required=True) + import_parser.add_argument("--source-sha256", required=True) + import_parser.add_argument("--source-row-count", type=int, required=True) + import_parser.add_argument("--source-file", type=Path, required=True) + return import_parser -async def import_job_architecture(args: argparse.Namespace) -> dict[str, object]: +async def import_job_architecture( + command_arguments: argparse.Namespace, +) -> dict[str, object]: """Validate one pinned snapshot before transactionally persisting it.""" - if not _SOURCE_CODE.fullmatch(args.source_system_code): + if not _SOURCE_SYSTEM_CODE_PATTERN.fullmatch(command_arguments.source_system_code): raise ValueError("source system code must be lower snake case") - for field in ("corporate_entity_code", "source_snapshot_code", "source_name"): - if not str(getattr(args, field)).strip(): - raise ValueError(f"{field} must not be blank") - _https_url(args.source_url, "source URL") - if not _SHA256.fullmatch(args.source_sha256): + for required_field_name in ( + "corporate_entity_code", + "source_snapshot_code", + "source_name", + ): + if not str(getattr(command_arguments, required_field_name)).strip(): + raise ValueError(f"{required_field_name} must not be blank") + _https_url(command_arguments.source_url, "source URL") + if not _SHA256_PATTERN.fullmatch(command_arguments.source_sha256): raise ValueError("source SHA-256 must be one digest") - if args.source_row_count <= 0 or not args.source_file.is_file(): + if ( + command_arguments.source_row_count <= 0 + or not command_arguments.source_file.is_file() + ): raise ValueError("source row count and file must be valid") - digest = hashlib.sha256(args.source_file.read_bytes()).hexdigest() - if digest != args.source_sha256.lower(): + source_artifact_sha256 = hashlib.sha256( + command_arguments.source_file.read_bytes() + ).hexdigest() + if source_artifact_sha256 != command_arguments.source_sha256.lower(): raise ValueError("source artifact SHA-256 mismatch") - nodes, edges, bindings, row_count = read_job_architecture(args.source_file) - if row_count != args.source_row_count: + ( + job_architecture_nodes, + hierarchy_edges, + occupation_bindings, + source_row_count, + ) = read_job_architecture(command_arguments.source_file) + if source_row_count != command_arguments.source_row_count: raise ValueError("source artifact row-count mismatch") - conn = await asyncpg.connect(args.target_dsn) + database_connection = await asyncpg.connect(command_arguments.target_dsn) try: - async with conn.transaction(): - entity_id = await conn.fetchval( + async with database_connection.transaction(): + corporate_entity_id = await database_connection.fetchval( "select corporate_entity_id from corporate_entity where corporate_entity_code = $1", - args.corporate_entity_code, + command_arguments.corporate_entity_code, ) - if entity_id is None: + if corporate_entity_id is None: raise ValueError("corporate entity must already exist") - key = (entity_id, args.source_system_code, args.source_snapshot_code) - await conn.execute( + source_snapshot_identity = ( + corporate_entity_id, + command_arguments.source_system_code, + command_arguments.source_snapshot_code, + ) + await database_connection.execute( """insert into job_architecture_source (corporate_entity_id, source_system_code, source_snapshot_code, source_name, source_artifact_url, source_artifact_sha256, @@ -265,13 +340,13 @@ async def import_job_architecture(args: argparse.Namespace) -> dict[str, object] excluded.source_artifact_url, excluded.source_artifact_sha256, excluded.source_row_count)""", - *key, - args.source_name, - args.source_url, - digest, - row_count, + *source_snapshot_identity, + command_arguments.source_name, + command_arguments.source_url, + source_artifact_sha256, + source_row_count, ) - await conn.executemany( + await database_connection.executemany( """insert into job_architecture_node (corporate_entity_id, source_system_code, source_snapshot_code, job_architecture_code, job_architecture_kind_code, @@ -290,9 +365,20 @@ async def import_job_architecture(args: argparse.Namespace) -> dict[str, object] excluded.job_architecture_name, excluded.job_architecture_description, excluded.valid_from, excluded.valid_to)""", - [(*key, n.code, n.kind, n.name, n.description, n.valid_from, n.valid_to) for n in nodes], + [ + ( + *source_snapshot_identity, + job_architecture_node.job_architecture_code, + job_architecture_node.job_architecture_kind_code, + job_architecture_node.job_architecture_name, + job_architecture_node.job_architecture_description, + job_architecture_node.valid_from, + job_architecture_node.valid_to, + ) + for job_architecture_node in job_architecture_nodes + ], ) - await conn.executemany( + await database_connection.executemany( """insert into job_architecture_hierarchy_edge (corporate_entity_id, source_system_code, source_snapshot_code, broader_job_architecture_code, narrower_job_architecture_code, @@ -304,9 +390,17 @@ async def import_job_architecture(args: argparse.Namespace) -> dict[str, object] do update set source_relation_code = excluded.source_relation_code where job_architecture_hierarchy_edge.source_relation_code is distinct from excluded.source_relation_code""", - [(*key, e.broader_code, e.narrower_code, e.source_relation_code) for e in edges], + [ + ( + *source_snapshot_identity, + hierarchy_edge.broader_code, + hierarchy_edge.narrower_code, + hierarchy_edge.source_relation_code, + ) + for hierarchy_edge in hierarchy_edges + ], ) - await conn.executemany( + await database_connection.executemany( """insert into job_architecture_occupation_binding (corporate_entity_id, source_system_code, source_snapshot_code, job_architecture_code, occupation_scheme_iri, @@ -319,22 +413,37 @@ async def import_job_architecture(args: argparse.Namespace) -> dict[str, object] do update set source_relation_code = excluded.source_relation_code where job_architecture_occupation_binding.source_relation_code is distinct from excluded.source_relation_code""", - [(*key, b.node_code, b.scheme_iri, b.scheme_version, b.occupation_code, b.source_relation_code) for b in bindings], + [ + ( + *source_snapshot_identity, + occupation_binding.job_architecture_code, + occupation_binding.occupation_scheme_iri, + occupation_binding.occupation_scheme_version, + occupation_binding.occupation_code, + occupation_binding.source_relation_code, + ) + for occupation_binding in occupation_bindings + ], ) finally: - await conn.close() + await database_connection.close() return { - "source_snapshot_code": args.source_snapshot_code, - "imported_nodes": len(nodes), - "imported_hierarchy_edges": len(edges), - "imported_occupation_bindings": len(bindings), - "source_sha256": digest, + "source_snapshot_code": command_arguments.source_snapshot_code, + "imported_nodes": len(job_architecture_nodes), + "imported_hierarchy_edges": len(hierarchy_edges), + "imported_occupation_bindings": len(occupation_bindings), + "source_sha256": source_artifact_sha256, } def main() -> None: """Run the importer and print aggregate, non-identifying evidence.""" - print(json.dumps(asyncio.run(import_job_architecture(_parser().parse_args())), sort_keys=True)) + command_arguments = _job_architecture_import_parser().parse_args() + print( + json.dumps( + asyncio.run(import_job_architecture(command_arguments)), sort_keys=True + ) + ) if __name__ == "__main__": diff --git a/tests/test_import_job_architecture.py b/tests/test_import_job_architecture.py index 965783cbf..db54f56ad 100644 --- a/tests/test_import_job_architecture.py +++ b/tests/test_import_job_architecture.py @@ -7,8 +7,7 @@ from scripts.import_job_architecture import read_job_architecture - -_FIELDS = [ +_SOURCE_COLUMN_NAMES = [ "Node Code", "Node Kind", "Node Name", @@ -24,27 +23,43 @@ ] -def _write(path: Path, rows: list[dict[str, str]]) -> Path: - with path.open("w", encoding="utf-8", newline="") as handle: - writer = csv.DictWriter(handle, fieldnames=_FIELDS) - writer.writeheader() - writer.writerows(rows) - return path - - -def _row(code: str, kind: str, name: str, **values: str) -> dict[str, str]: - row = dict.fromkeys(_FIELDS, "") - row.update({"Node Code": code, "Node Kind": kind, "Node Name": name}, **values) - return row +def _write_source_snapshot( + source_file_path: Path, source_rows: list[dict[str, str]] +) -> Path: + with source_file_path.open("w", encoding="utf-8", newline="") as source_file: + source_row_writer = csv.DictWriter(source_file, fieldnames=_SOURCE_COLUMN_NAMES) + source_row_writer.writeheader() + source_row_writer.writerows(source_rows) + return source_file_path + + +def _source_row( + job_architecture_code: str, + job_architecture_kind_code: str, + job_architecture_name: str, + **additional_values: str, +) -> dict[str, str]: + source_row = dict.fromkeys(_SOURCE_COLUMN_NAMES, "") + source_row.update( + { + "Node Code": job_architecture_code, + "Node Kind": job_architecture_kind_code, + "Node Name": job_architecture_name, + }, + **additional_values, + ) + return source_row -def test_snapshot_preserves_multiple_membership_and_explicit_binding(tmp_path: Path) -> None: - path = _write( +def test_snapshot_preserves_multiple_membership_and_explicit_binding( + tmp_path: Path, +) -> None: + source_file_path = _write_source_snapshot( tmp_path / "architecture.csv", [ - _row("F-A", "job_family", "Synthetic family A"), - _row("F-B", "job_family", "Synthetic family B"), - _row( + _source_row("F-A", "job_family", "Synthetic family A"), + _source_row("F-B", "job_family", "Synthetic family B"), + _source_row( "S-1", "job_series", "Synthetic series", @@ -58,7 +73,7 @@ def test_snapshot_preserves_multiple_membership_and_explicit_binding(tmp_path: P "Occupation Relation": "source_classification", }, ), - _row( + _source_row( "S-1", "job_series", "Synthetic series", @@ -75,53 +90,90 @@ def test_snapshot_preserves_multiple_membership_and_explicit_binding(tmp_path: P ], ) - nodes, edges, bindings, row_count = read_job_architecture(path) - - assert row_count == 4 - assert len(nodes) == 3 - assert {(edge.broader_code, edge.narrower_code) for edge in edges} == { + ( + job_architecture_nodes, + hierarchy_edges, + occupation_bindings, + source_row_count, + ) = read_job_architecture(source_file_path) + + assert source_row_count == 4 + assert len(job_architecture_nodes) == 3 + assert { + (hierarchy_edge.broader_code, hierarchy_edge.narrower_code) + for hierarchy_edge in hierarchy_edges + } == { ("F-A", "S-1"), ("F-B", "S-1"), } - assert len(bindings) == 1 - assert bindings[0].occupation_code == "SYN-1" + assert len(occupation_bindings) == 1 + assert occupation_bindings[0].occupation_code == "SYN-1" def test_label_never_creates_an_occupation_binding(tmp_path: Path) -> None: - path = _write( + source_file_path = _write_source_snapshot( tmp_path / "unbound.csv", - [_row("S-1", "job_series", "15-1252 Software developers")], + [_source_row("S-1", "job_series", "15-1252 Software developers")], ) - _, _, bindings, _ = read_job_architecture(path) + _, _, occupation_bindings, _ = read_job_architecture(source_file_path) - assert bindings == [] + assert occupation_bindings == [] @pytest.mark.parametrize( - ("rows", "message"), + ("source_rows", "expected_message"), [ ( [ - _row("F-A", "job_family", "Family", **{"Parent Code": "S-1", "Hierarchy Relation": "broader"}), - _row("S-1", "job_series", "Series", **{"Parent Code": "F-A", "Hierarchy Relation": "broader"}), + _source_row( + "F-A", + "job_family", + "Family", + **{"Parent Code": "S-1", "Hierarchy Relation": "broader"}, + ), + _source_row( + "S-1", + "job_series", + "Series", + **{"Parent Code": "F-A", "Hierarchy Relation": "broader"}, + ), ], "cyclic", ), ( - [_row("S-1", "job_series", "Series", **{"Occupation Scheme IRI": "https://example.test/scheme"})], + [ + _source_row( + "S-1", + "job_series", + "Series", + **{"Occupation Scheme IRI": "https://example.test/scheme"}, + ) + ], "partial occupation binding", ), ( - [_row("S-1", "job_series", "Series", **{"Parent Code": "missing", "Hierarchy Relation": "broader"})], + [ + _source_row( + "S-1", + "job_series", + "Series", + **{ + "Parent Code": "missing", + "Hierarchy Relation": "broader", + }, + ) + ], "unknown parent", ), ], ) def test_invalid_source_relationships_fail_closed( tmp_path: Path, - rows: list[dict[str, str]], - message: str, + source_rows: list[dict[str, str]], + expected_message: str, ) -> None: - with pytest.raises(ValueError, match=message): - read_job_architecture(_write(tmp_path / "invalid.csv", rows)) + with pytest.raises(ValueError, match=expected_message): + read_job_architecture( + _write_source_snapshot(tmp_path / "invalid.csv", source_rows) + ) diff --git a/tests/test_import_job_architecture_semantic_identifiers.py b/tests/test_import_job_architecture_semantic_identifiers.py index 86e45cd2a..0f9a92d47 100644 --- a/tests/test_import_job_architecture_semantic_identifiers.py +++ b/tests/test_import_job_architecture_semantic_identifiers.py @@ -5,8 +5,12 @@ import ast from pathlib import Path - _GENERIC_OWNED_NAMES = { + "_FIELDS", + "_KINDS", + "_SHA256", + "_SOURCE_CODE", + "_parser", "args", "bindings", "child",