From af29e464f516c93cfc2fa1ac476135f0c7b400d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 14:31:16 +0000 Subject: [PATCH 01/15] chore(codex): bootstrap issue #239 --- agents/codex-239.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 agents/codex-239.md diff --git a/agents/codex-239.md b/agents/codex-239.md new file mode 100644 index 00000000..1d7f1d95 --- /dev/null +++ b/agents/codex-239.md @@ -0,0 +1 @@ + From 1d15322be832585639b6f6cb4d89c204fe2e110e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:44:08 +0000 Subject: [PATCH 02/15] chore(codex-keepalive): apply updates (PR #249) --- src/counter_risk/normalize.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/counter_risk/normalize.py b/src/counter_risk/normalize.py index 0b8fb5dc..8450ef71 100644 --- a/src/counter_risk/normalize.py +++ b/src/counter_risk/normalize.py @@ -144,6 +144,26 @@ def normalize_counterparty(name: str) -> str: return resolve_counterparty(name).canonical_name +def resolve_clearing_house( + name: str, + *, + registry_path: str | Path = Path("config/name_registry.yml"), +) -> NameResolution: + """Resolve clearing house name with registry-first semantics.""" + + normalized = canonicalize_name(name) + alias_lookup = _load_alias_lookup(str(Path(registry_path).resolve())) + registry_match = alias_lookup.get(normalized.casefold()) + if registry_match is not None: + return NameResolution(raw_name=name, canonical_name=registry_match, source="registry") + + fallback_match = _CLEARING_HOUSE_FALLBACK_MAPPINGS.get(normalized) + if fallback_match is not None: + return NameResolution(raw_name=name, canonical_name=fallback_match, source="fallback") + + return NameResolution(raw_name=name, canonical_name=normalized, source="unmapped") + + def normalize_clearing_house(name: str) -> str: """Normalize a clearing house name to the canonical historical workbook label.""" From 3b46e81722fa20b0417119676d15d1ec85763de9 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 14:51:58 +0000 Subject: [PATCH 03/15] test: cover registry-source clearing house resolution --- tests/test_normalization_registry_first.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index e9b8ade6..f12bd1b1 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -8,7 +8,7 @@ import pytest -from counter_risk.normalize import resolve_counterparty +from counter_risk.normalize import resolve_clearing_house, resolve_counterparty from counter_risk.pipeline.run import reconcile_series_coverage from counter_risk.reports.mapping_diff import generate_mapping_diff_report @@ -85,6 +85,31 @@ def test_resolve_counterparty_uses_registry_direct_canonical_match_before_fallba assert canonical_key_match.source == "registry" +def test_resolve_clearing_house_returns_registry_source_when_name_is_in_registry( + tmp_path: Path, +) -> None: + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + " - canonical_key: custom_ch", + " display_name: Custom Clearing House", + " aliases:", + " - Custom CH", + ] + ) + + "\n", + encoding="utf-8", + ) + + resolution = resolve_clearing_house("Custom CH", registry_path=registry_path) + + assert resolution.canonical_name == "Custom Clearing House" + assert resolution.source == "registry" + + def test_reconciliation_with_after_registry_has_no_societe_generale_warning( caplog: pytest.LogCaptureFixture, tmp_path: Path, From 09259b2a37eeede67d7a03cd58b79a436d6ea07c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 15:03:11 +0000 Subject: [PATCH 04/15] Document source metadata in counterparty normalization API --- src/counter_risk/normalize.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/counter_risk/normalize.py b/src/counter_risk/normalize.py index 8450ef71..829a1817 100644 --- a/src/counter_risk/normalize.py +++ b/src/counter_risk/normalize.py @@ -144,6 +144,23 @@ def normalize_counterparty(name: str) -> str: return resolve_counterparty(name).canonical_name +def normalize_counterparty_with_source( + name: str, + *, + registry_path: str | Path = Path("config/name_registry.yml"), +) -> NameResolution: + """Normalize a counterparty name and return full mapping metadata. + + The returned :class:`NameResolution` includes ``source`` indicating where + the mapping came from: + - ``"registry"`` when matched via configured name registry entries/aliases. + - ``"fallback"`` when matched via built-in fallback mappings. + - ``"unmapped"`` when no mapping is found and canonicalized input is used. + """ + + return resolve_counterparty(name, registry_path=registry_path) + + def resolve_clearing_house( name: str, *, From 6fa9f0a9629f80b9e3dc87d8b85986982b27870c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 15:10:00 +0000 Subject: [PATCH 05/15] Use source-aware counterparty normalization in reconciliation --- src/counter_risk/pipeline/run.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 4a2a11c8..2d01ace0 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -17,7 +17,11 @@ from counter_risk.config import WorkflowConfig, load_config from counter_risk.dates import derive_as_of_date, derive_run_date -from counter_risk.normalize import canonicalize_name, normalize_counterparty, resolve_counterparty +from counter_risk.normalize import ( + canonicalize_name, + normalize_counterparty, + normalize_counterparty_with_source, +) from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( @@ -405,7 +409,7 @@ def _counterparty_resolution_maps_from_records( raw_name = str(record.get("counterparty", "")).strip() if not raw_name: continue - resolution = resolve_counterparty(raw_name) + resolution = normalize_counterparty_with_source(raw_name) normalized_to_raw.setdefault(resolution.canonical_name, set()).add(raw_name) sources_by_raw_name[raw_name] = resolution.source return normalized_to_raw, sources_by_raw_name From 3fcf275af64fb9981f91b66938dd1bf39b4026bb Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 15:20:07 +0000 Subject: [PATCH 06/15] Add Soc Gen Inc alias to default name registry --- config/name_registry.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/name_registry.yml b/config/name_registry.yml index 891dc282..702e7784 100644 --- a/config/name_registry.yml +++ b/config/name_registry.yml @@ -34,6 +34,7 @@ entries: display_name: Soc Gen aliases: - Soc Gen + - Soc Gen Inc - Societe Generale - canonical_key: barclays From 46184203aeb25dab844213fbb64ad38bd6531a96 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 15:34:15 +0000 Subject: [PATCH 07/15] Add output-format forwarding coverage for mapping diff CLI --- src/counter_risk/cli/mapping_diff_report.py | 12 +++++- src/counter_risk/reports/mapping_diff.py | 5 +++ tests/test_mapping_diff_report_cli.py | 47 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/counter_risk/cli/mapping_diff_report.py b/src/counter_risk/cli/mapping_diff_report.py index 51535620..54b95e0e 100644 --- a/src/counter_risk/cli/mapping_diff_report.py +++ b/src/counter_risk/cli/mapping_diff_report.py @@ -34,6 +34,12 @@ def build_parser() -> argparse.ArgumentParser: default=[], help="Raw input name observed during reconciliation. Can be provided multiple times.", ) + parser.add_argument( + "--output-format", + choices=("text",), + default="text", + help="Report output format.", + ) return parser @@ -48,7 +54,11 @@ def main(argv: list[str] | None = None) -> int: "reconciliation": list(args.reconciliation_name), } try: - report = generate_mapping_diff_report(args.registry, input_sources) + report = generate_mapping_diff_report( + args.registry, + input_sources, + output_format=args.output_format, + ) except ValueError as exc: error_line = " ".join(str(exc).splitlines()) print(error_line, file=sys.stderr) diff --git a/src/counter_risk/reports/mapping_diff.py b/src/counter_risk/reports/mapping_diff.py index b75a5e13..7cf49ebc 100644 --- a/src/counter_risk/reports/mapping_diff.py +++ b/src/counter_risk/reports/mapping_diff.py @@ -114,9 +114,14 @@ def _iter_input_names(input_sources: Mapping[str, Any]) -> Iterable[str]: def generate_mapping_diff_report( registry_path: str | Path, input_sources: Mapping[str, Any], + *, + output_format: str = "text", ) -> str: """Generate a deterministic mapping diff report.""" + if output_format != "text": + raise ValueError(f"Unsupported output format: {output_format}") + # Load once so missing/unreadable/invalid registry is treated as fatal for report generation. load_name_registry(registry_path) diff --git a/tests/test_mapping_diff_report_cli.py b/tests/test_mapping_diff_report_cli.py index 71d8c239..921d7b56 100644 --- a/tests/test_mapping_diff_report_cli.py +++ b/tests/test_mapping_diff_report_cli.py @@ -8,6 +8,8 @@ import sys from pathlib import Path +from counter_risk.cli import mapping_diff_report + def _cli_cmd() -> list[str]: return [sys.executable, "-m", "counter_risk.cli.mapping_diff_report"] @@ -119,3 +121,48 @@ def test_mapping_diff_report_deterministic_sections(tmp_path: Path) -> None: assert "UNMAPPED\nUnknown House\n" in first.stdout assert "FALLBACK_MAPPED\nSociete Generale -> Soc Gen\n" in first.stdout assert "SUGGESTIONS\nUnknown House -> Unknown House\n" in first.stdout + + +def test_mapping_diff_report_forwards_output_format_parameter( + tmp_path: Path, + monkeypatch, +) -> None: + captured_call: dict[str, object] = {} + + def _fake_generate_mapping_diff_report( + registry_path: Path, + input_sources: dict[str, list[str]], + *, + output_format: str = "text", + ) -> str: + captured_call["registry_path"] = registry_path + captured_call["input_sources"] = input_sources + captured_call["output_format"] = output_format + return "UNMAPPED\n\nFALLBACK_MAPPED\n\nSUGGESTIONS\n" + + monkeypatch.setattr( + mapping_diff_report, + "generate_mapping_diff_report", + _fake_generate_mapping_diff_report, + ) + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text("schema_version: 1\nentries: []\n", encoding="utf-8") + + exit_code = mapping_diff_report.main( + [ + "--registry", + str(registry_path), + "--output-format", + "text", + "--normalization-name", + "Societe Generale", + ] + ) + + assert exit_code == 0 + assert captured_call["registry_path"] == registry_path + assert captured_call["input_sources"] == { + "normalization": ["Societe Generale"], + "reconciliation": [], + } + assert captured_call["output_format"] == "text" From de0ef73ca5de291fae133453bf0dd8718c17be8c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:54:00 +0000 Subject: [PATCH 08/15] chore(codex-keepalive): apply updates (PR #249) --- tests/fixtures/fallback_mapped_names.csv | 4 ++++ tests/fixtures/unmapped_names.csv | 3 +++ tests/test_mapping_diff_report_cli.py | 26 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 tests/fixtures/fallback_mapped_names.csv create mode 100644 tests/fixtures/unmapped_names.csv diff --git a/tests/fixtures/fallback_mapped_names.csv b/tests/fixtures/fallback_mapped_names.csv new file mode 100644 index 00000000..cef358a6 --- /dev/null +++ b/tests/fixtures/fallback_mapped_names.csv @@ -0,0 +1,4 @@ +raw_name +Societe Generale +Citigroup +Bank of America, NA diff --git a/tests/fixtures/unmapped_names.csv b/tests/fixtures/unmapped_names.csv new file mode 100644 index 00000000..f0bcdf17 --- /dev/null +++ b/tests/fixtures/unmapped_names.csv @@ -0,0 +1,3 @@ +raw_name +Unknown House +Zeta Broker diff --git a/tests/test_mapping_diff_report_cli.py b/tests/test_mapping_diff_report_cli.py index 921d7b56..6410dd0b 100644 --- a/tests/test_mapping_diff_report_cli.py +++ b/tests/test_mapping_diff_report_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import csv import os import stat import subprocess @@ -24,6 +25,13 @@ def _cli_env() -> dict[str, str]: return env +def _load_fixture_names(filename: str) -> list[str]: + fixture_path = Path("tests/fixtures") / filename + with fixture_path.open(newline="", encoding="utf-8") as fixture_file: + reader = csv.DictReader(fixture_file) + return [row["raw_name"] for row in reader] + + def test_mapping_diff_report_help_exits_zero() -> None: result = subprocess.run( [*_cli_cmd(), "--help"], @@ -123,6 +131,24 @@ def test_mapping_diff_report_deterministic_sections(tmp_path: Path) -> None: assert "SUGGESTIONS\nUnknown House -> Unknown House\n" in first.stdout +def test_mapping_diff_report_with_fixture_inputs_contains_required_sections() -> None: + fallback_names = _load_fixture_names("fallback_mapped_names.csv") + unmapped_names = _load_fixture_names("unmapped_names.csv") + + args: list[str] = [*_cli_cmd(), "--registry", "config/name_registry.yml"] + for name in fallback_names + unmapped_names: + args.extend(["--normalization-name", name]) + for name in unmapped_names: + args.extend(["--reconciliation-name", name]) + + result = subprocess.run(args, check=False, capture_output=True, text=True, env=_cli_env()) + + assert result.returncode == 0 + assert "UNMAPPED" in result.stdout + assert "FALLBACK_MAPPED" in result.stdout + assert "SUGGESTIONS" in result.stdout + + def test_mapping_diff_report_forwards_output_format_parameter( tmp_path: Path, monkeypatch, From 40cf31be6fc10dba2b329fee06367facef020d2a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 16:12:33 +0000 Subject: [PATCH 09/15] Fix clearing-house registry-first normalization and source diff test --- src/counter_risk/normalize.py | 3 +- tests/test_normalization_registry_first.py | 45 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/counter_risk/normalize.py b/src/counter_risk/normalize.py index 829a1817..11cf7e11 100644 --- a/src/counter_risk/normalize.py +++ b/src/counter_risk/normalize.py @@ -184,5 +184,4 @@ def resolve_clearing_house( def normalize_clearing_house(name: str) -> str: """Normalize a clearing house name to the canonical historical workbook label.""" - normalized = _normalize_whitespace(name) - return _CLEARING_HOUSE_FALLBACK_MAPPINGS.get(normalized, normalized) + return resolve_clearing_house(name).canonical_name diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index f12bd1b1..ac473646 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -8,13 +8,14 @@ import pytest +import counter_risk.pipeline.run as pipeline_run from counter_risk.normalize import resolve_clearing_house, resolve_counterparty from counter_risk.pipeline.run import reconcile_series_coverage from counter_risk.reports.mapping_diff import generate_mapping_diff_report def _fixture_path(name: str) -> Path: - return Path("tests/fixtures") / name + return Path(__file__).resolve().parent / "fixtures" / name def _input_sources() -> dict[str, object]: @@ -134,3 +135,45 @@ def test_reconciliation_with_after_registry_has_no_societe_generale_warning( assert result["warnings"] assert not any("Societe Generale" in warning for warning in result["warnings"]) assert all("Societe Generale" not in record.getMessage() for record in caplog.records) + + +def test_reconciliation_sources_differ_between_before_and_after_registry( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _run_with_fixture(fixture_name: str, run_dir: Path) -> list[str]: + fixture_path = _fixture_path(fixture_name).resolve() + config_dir = run_dir / "config" + config_dir.mkdir(parents=True) + shutil.copyfile( + fixture_path, + config_dir / "name_registry.yml", + ) + monkeypatch.chdir(run_dir) + + captured_sources: list[str] = [] + original = pipeline_run.normalize_counterparty_with_source + + def _capture_source(raw_name: str): + resolution = original(raw_name) + captured_sources.append(resolution.source) + return resolution + + monkeypatch.setattr(pipeline_run, "normalize_counterparty_with_source", _capture_source) + reconcile_series_coverage( + parsed_data_by_sheet={ + "Total": { + "totals": [{"counterparty": "Societe Generale"}], + "futures": [], + } + }, + historical_series_headers_by_sheet={"Total": ("Soc Gen Inc",)}, + ) + return captured_sources + + before_sources = _run_with_fixture("name_registry_before.yml", tmp_path / "before") + after_sources = _run_with_fixture("name_registry_after.yml", tmp_path / "after") + + assert "fallback" in before_sources + assert "registry" in after_sources + assert before_sources != after_sources From d118bf20f64d05761aeefa5b3ee3f67fb192b63c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 16:16:34 +0000 Subject: [PATCH 10/15] test(fixtures): expand after-registry mappings for integration parity --- tests/fixtures/name_registry_after.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/fixtures/name_registry_after.yml b/tests/fixtures/name_registry_after.yml index c13c49c9..fc31df8a 100644 --- a/tests/fixtures/name_registry_after.yml +++ b/tests/fixtures/name_registry_after.yml @@ -5,3 +5,8 @@ entries: aliases: - Soc Gen Inc - Societe Generale + - canonical_key: jp_morgan_chase + display_name: JPMorgan Chase + aliases: + - J.P. Morgan + - JP Morgan Chase From 2a5e6f3c963f1273370bb5b7faa2a8c0a56f71fe Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 16:36:38 +0000 Subject: [PATCH 11/15] Add explicit acceptance tests for registry source and CLI params --- tests/test_mapping_diff_report_cli.py | 33 ++++++++++++++++++ tests/test_normalization_registry_first.py | 40 +++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/test_mapping_diff_report_cli.py b/tests/test_mapping_diff_report_cli.py index 6410dd0b..47eebbe8 100644 --- a/tests/test_mapping_diff_report_cli.py +++ b/tests/test_mapping_diff_report_cli.py @@ -149,6 +149,39 @@ def test_mapping_diff_report_with_fixture_inputs_contains_required_sections() -> assert "SUGGESTIONS" in result.stdout +def test_mapping_diff_report_forwards_registry_path_parameter( + tmp_path: Path, + monkeypatch, +) -> None: + captured_call: dict[str, object] = {} + + def _fake_generate_mapping_diff_report( + registry_path: Path, + input_sources: dict[str, list[str]], + *, + output_format: str = "text", + ) -> str: + captured_call["registry_path"] = registry_path + captured_call["input_sources"] = input_sources + captured_call["output_format"] = output_format + return "UNMAPPED\n\nFALLBACK_MAPPED\n\nSUGGESTIONS\n" + + monkeypatch.setattr( + mapping_diff_report, + "generate_mapping_diff_report", + _fake_generate_mapping_diff_report, + ) + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text("schema_version: 1\nentries: []\n", encoding="utf-8") + + exit_code = mapping_diff_report.main(["--registry", str(registry_path)]) + + assert exit_code == 0 + assert captured_call["registry_path"] == registry_path + assert captured_call["input_sources"] == {"normalization": [], "reconciliation": []} + assert captured_call["output_format"] == "text" + + def test_mapping_diff_report_forwards_output_format_parameter( tmp_path: Path, monkeypatch, diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index ac473646..e27eb6b2 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -9,7 +9,11 @@ import pytest import counter_risk.pipeline.run as pipeline_run -from counter_risk.normalize import resolve_clearing_house, resolve_counterparty +from counter_risk.normalize import ( + normalize_counterparty_with_source, + resolve_clearing_house, + resolve_counterparty, +) from counter_risk.pipeline.run import reconcile_series_coverage from counter_risk.reports.mapping_diff import generate_mapping_diff_report @@ -111,6 +115,40 @@ def test_resolve_clearing_house_returns_registry_source_when_name_is_in_registry assert resolution.source == "registry" +def test_resolve_clearing_house_returns_fallback_source_when_registry_has_no_match( + tmp_path: Path, +) -> None: + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text("schema_version: 1\nentries: []\n", encoding="utf-8") + + resolution = resolve_clearing_house("ICE Clear US", registry_path=registry_path) + + assert resolution.canonical_name == "ICE" + assert resolution.source == "fallback" + + +def test_normalize_counterparty_with_source_exposes_source_attribute(tmp_path: Path) -> None: + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + " - canonical_key: soc_gen", + " display_name: Soc Gen", + " aliases:", + " - SG", + ] + ) + + "\n", + encoding="utf-8", + ) + + resolution = normalize_counterparty_with_source("SG", registry_path=registry_path) + + assert resolution.source == "registry" + + def test_reconciliation_with_after_registry_has_no_societe_generale_warning( caplog: pytest.LogCaptureFixture, tmp_path: Path, From 8919f1fea61d50312aeb5ad68c01bf7b6bcf81e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:48:59 +0000 Subject: [PATCH 12/15] chore(codex-keepalive): apply updates (PR #249) --- src/counter_risk/normalize.py | 9 +++++++-- tests/test_normalization_registry_first.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/counter_risk/normalize.py b/src/counter_risk/normalize.py index 11cf7e11..181b0d52 100644 --- a/src/counter_risk/normalize.py +++ b/src/counter_risk/normalize.py @@ -166,7 +166,12 @@ def resolve_clearing_house( *, registry_path: str | Path = Path("config/name_registry.yml"), ) -> NameResolution: - """Resolve clearing house name with registry-first semantics.""" + """Resolve clearing house name with registry-first semantics. + + Clearing-house source attribution is binary for reconciliation reporting: + registry hits are labeled ``"registry"``, and all non-registry paths are + labeled ``"fallback"`` (including identity/no-op normalization). + """ normalized = canonicalize_name(name) alias_lookup = _load_alias_lookup(str(Path(registry_path).resolve())) @@ -178,7 +183,7 @@ def resolve_clearing_house( if fallback_match is not None: return NameResolution(raw_name=name, canonical_name=fallback_match, source="fallback") - return NameResolution(raw_name=name, canonical_name=normalized, source="unmapped") + return NameResolution(raw_name=name, canonical_name=normalized, source="fallback") def normalize_clearing_house(name: str) -> str: diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index e27eb6b2..ff928db0 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -127,6 +127,18 @@ def test_resolve_clearing_house_returns_fallback_source_when_registry_has_no_mat assert resolution.source == "fallback" +def test_resolve_clearing_house_unknown_name_uses_identity_with_fallback_source( + tmp_path: Path, +) -> None: + registry_path = tmp_path / "name_registry.yml" + registry_path.write_text("schema_version: 1\nentries: []\n", encoding="utf-8") + + resolution = resolve_clearing_house("LCH", registry_path=registry_path) + + assert resolution.canonical_name == "LCH" + assert resolution.source == "fallback" + + def test_normalize_counterparty_with_source_exposes_source_attribute(tmp_path: Path) -> None: registry_path = tmp_path / "name_registry.yml" registry_path.write_text( From b5af361d411870e6487cbe71bec978b1d450a3de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:56:27 +0000 Subject: [PATCH 13/15] chore(codex-keepalive): apply updates (PR #249) --- agents/codex-239.md | 1 - src/counter_risk/pipeline/run.py | 109 +++++++++++++++++++-- tests/test_normalization_registry_first.py | 4 +- 3 files changed, 102 insertions(+), 12 deletions(-) delete mode 100644 agents/codex-239.md diff --git a/agents/codex-239.md b/agents/codex-239.md deleted file mode 100644 index 1d7f1d95..00000000 --- a/agents/codex-239.md +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 2d01ace0..54f42470 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -17,11 +17,7 @@ from counter_risk.config import WorkflowConfig, load_config from counter_risk.dates import derive_as_of_date, derive_run_date -from counter_risk.normalize import ( - canonicalize_name, - normalize_counterparty, - normalize_counterparty_with_source, -) +from counter_risk.normalize import canonicalize_name, normalize_counterparty, resolve_counterparty from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( @@ -409,7 +405,7 @@ def _counterparty_resolution_maps_from_records( raw_name = str(record.get("counterparty", "")).strip() if not raw_name: continue - resolution = normalize_counterparty_with_source(raw_name) + resolution = resolve_counterparty(raw_name) normalized_to_raw.setdefault(resolution.canonical_name, set()).add(raw_name) sources_by_raw_name[raw_name] = resolution.source return normalized_to_raw, sources_by_raw_name @@ -617,7 +613,7 @@ def _extract_header_text_lines( workbook_path: Path, *, max_rows: int = 15, max_cols: int = 6 ) -> list[str]: try: - from openpyxl import load_workbook # type: ignore[import-untyped] + from openpyxl import load_workbook except ModuleNotFoundError: return [] except Exception: @@ -967,8 +963,17 @@ def _write_outputs( target_master_ppt, ) else: - _derive_distribution_ppt( + chart_replaced_ppt = run_dir / f"{target_master_ppt.stem}_chart_replaced.pptx" + chart_replacement_applied = _apply_chart_replacement( master_pptx_path=target_master_ppt, + output_path=chart_replaced_ppt, + run_dir=run_dir, + static_mode=config.distribution_static, + warnings=warnings, + ) + distribution_source = chart_replaced_ppt if chart_replacement_applied else target_master_ppt + _derive_distribution_ppt( + master_pptx_path=distribution_source, distribution_pptx_path=target_distribution_ppt, ) try: @@ -1017,6 +1022,92 @@ def _write_outputs( return output_paths, refresh_result +def _apply_chart_replacement( + *, + master_pptx_path: Path, + output_path: Path, + run_dir: Path, + static_mode: bool, + warnings: list[str], +) -> bool: + """Replace chart/OLE shapes with static images using confidence-based matching. + + Opens a COM session to export chart shapes and fallback slide images, then + runs ``_rebuild_pptx_replacing_charts`` with confidence checks. Returns + ``True`` when chart replacement was applied, ``False`` when skipped. + """ + + if platform.system().lower() != "windows": + return False + + try: + import win32com.client + except ImportError: + return False + + chart_images_dir = run_dir / "_chart_images" + chart_images_dir.mkdir(parents=True, exist_ok=True) + + app = None + presentation = None + chart_images: dict[tuple[int, str], Path] = {} + fallback_slide_images: dict[int, Path] = {} + try: + app = win32com.client.DispatchEx("PowerPoint.Application") + app.Visible = False + presentation = app.Presentations.Open(str(master_pptx_path), WithWindow=False) + + chart_images = _export_chart_shapes_as_images( + com_presentation=presentation, + slide_images_dir=chart_images_dir, + warnings=warnings, + ) + + if chart_images: + slide_count = int(presentation.Slides.Count) + for slide_idx in range(1, slide_count + 1): + img_path = chart_images_dir / f"fallback_slide_{slide_idx:04d}.png" + try: + presentation.Slides[slide_idx].Export(str(img_path), "PNG") + fallback_slide_images[slide_idx] = img_path + except Exception as exc: + warnings.append( + f"chart_replacement fallback slide export failed (slide {slide_idx}): {exc}" + ) + except Exception as exc: + warnings.append(f"chart_replacement COM session failed: {exc}") + LOGGER.warning("chart_replacement_com_failed exc=%s", exc) + return False + finally: + if presentation is not None: + with contextlib.suppress(Exception): + presentation.Close() + if app is not None: + with contextlib.suppress(Exception): + app.Quit() + + if not chart_images: + return False + + try: + _rebuild_pptx_replacing_charts( + source_pptx=master_pptx_path, + output_path=output_path, + chart_images=chart_images, + fallback_slide_images=fallback_slide_images, + fallback_to_full_deck_rebuild=static_mode, + ) + LOGGER.info("chart_replacement_complete output=%s", output_path) + return True + except RuntimeError as exc: + if static_mode and "full-deck static rebuild" in str(exc): + LOGGER.info("chart_replacement_deferred_to_static_rebuild: %s", exc) + else: + LOGGER.warning("chart_replacement_failed exc=%s", exc) + warnings.append(f"chart_replacement failed: {exc}") + return False + + def _derive_distribution_ppt(*, master_pptx_path: Path, distribution_pptx_path: Path) -> None: """Derive the distribution PPT from the generated Master PPT.""" @@ -1165,7 +1256,7 @@ def _refresh_ppt_links(pptx_path: Path) -> PptProcessingResult: ) try: - import win32com.client # type: ignore[import-untyped] + import win32com.client except ImportError: LOGGER.info("ppt_link_refresh_skipped file=%s reason=win32com_unavailable", pptx_path) return PptProcessingResult( diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index ff928db0..7e99f4c9 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -202,14 +202,14 @@ def _run_with_fixture(fixture_name: str, run_dir: Path) -> list[str]: monkeypatch.chdir(run_dir) captured_sources: list[str] = [] - original = pipeline_run.normalize_counterparty_with_source + original = pipeline_run.resolve_counterparty def _capture_source(raw_name: str): resolution = original(raw_name) captured_sources.append(resolution.source) return resolution - monkeypatch.setattr(pipeline_run, "normalize_counterparty_with_source", _capture_source) + monkeypatch.setattr(pipeline_run, "resolve_counterparty", _capture_source) reconcile_series_coverage( parsed_data_by_sheet={ "Total": { From 04dbf663fe4c476a8ad1a64475792287aa49756e Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 17:08:11 +0000 Subject: [PATCH 14/15] chore(scope): remove out-of-scope pipeline delta from PR --- src/counter_risk/pipeline/run.py | 101 +------------------------------ 1 file changed, 3 insertions(+), 98 deletions(-) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 54f42470..4a2a11c8 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -613,7 +613,7 @@ def _extract_header_text_lines( workbook_path: Path, *, max_rows: int = 15, max_cols: int = 6 ) -> list[str]: try: - from openpyxl import load_workbook + from openpyxl import load_workbook # type: ignore[import-untyped] except ModuleNotFoundError: return [] except Exception: @@ -963,17 +963,8 @@ def _write_outputs( target_master_ppt, ) else: - chart_replaced_ppt = run_dir / f"{target_master_ppt.stem}_chart_replaced.pptx" - chart_replacement_applied = _apply_chart_replacement( - master_pptx_path=target_master_ppt, - output_path=chart_replaced_ppt, - run_dir=run_dir, - static_mode=config.distribution_static, - warnings=warnings, - ) - distribution_source = chart_replaced_ppt if chart_replacement_applied else target_master_ppt _derive_distribution_ppt( - master_pptx_path=distribution_source, + master_pptx_path=target_master_ppt, distribution_pptx_path=target_distribution_ppt, ) try: @@ -1022,92 +1013,6 @@ def _write_outputs( return output_paths, refresh_result -def _apply_chart_replacement( - *, - master_pptx_path: Path, - output_path: Path, - run_dir: Path, - static_mode: bool, - warnings: list[str], -) -> bool: - """Replace chart/OLE shapes with static images using confidence-based matching. - - Opens a COM session to export chart shapes and fallback slide images, then - runs ``_rebuild_pptx_replacing_charts`` with confidence checks. Returns - ``True`` when chart replacement was applied, ``False`` when skipped. - """ - - if platform.system().lower() != "windows": - return False - - try: - import win32com.client - except ImportError: - return False - - chart_images_dir = run_dir / "_chart_images" - chart_images_dir.mkdir(parents=True, exist_ok=True) - - app = None - presentation = None - chart_images: dict[tuple[int, str], Path] = {} - fallback_slide_images: dict[int, Path] = {} - try: - app = win32com.client.DispatchEx("PowerPoint.Application") - app.Visible = False - presentation = app.Presentations.Open(str(master_pptx_path), WithWindow=False) - - chart_images = _export_chart_shapes_as_images( - com_presentation=presentation, - slide_images_dir=chart_images_dir, - warnings=warnings, - ) - - if chart_images: - slide_count = int(presentation.Slides.Count) - for slide_idx in range(1, slide_count + 1): - img_path = chart_images_dir / f"fallback_slide_{slide_idx:04d}.png" - try: - presentation.Slides[slide_idx].Export(str(img_path), "PNG") - fallback_slide_images[slide_idx] = img_path - except Exception as exc: - warnings.append( - f"chart_replacement fallback slide export failed (slide {slide_idx}): {exc}" - ) - except Exception as exc: - warnings.append(f"chart_replacement COM session failed: {exc}") - LOGGER.warning("chart_replacement_com_failed exc=%s", exc) - return False - finally: - if presentation is not None: - with contextlib.suppress(Exception): - presentation.Close() - if app is not None: - with contextlib.suppress(Exception): - app.Quit() - - if not chart_images: - return False - - try: - _rebuild_pptx_replacing_charts( - source_pptx=master_pptx_path, - output_path=output_path, - chart_images=chart_images, - fallback_slide_images=fallback_slide_images, - fallback_to_full_deck_rebuild=static_mode, - ) - LOGGER.info("chart_replacement_complete output=%s", output_path) - return True - except RuntimeError as exc: - if static_mode and "full-deck static rebuild" in str(exc): - LOGGER.info("chart_replacement_deferred_to_static_rebuild: %s", exc) - else: - LOGGER.warning("chart_replacement_failed exc=%s", exc) - warnings.append(f"chart_replacement failed: {exc}") - return False - - def _derive_distribution_ppt(*, master_pptx_path: Path, distribution_pptx_path: Path) -> None: """Derive the distribution PPT from the generated Master PPT.""" @@ -1256,7 +1161,7 @@ def _refresh_ppt_links(pptx_path: Path) -> PptProcessingResult: ) try: - import win32com.client + import win32com.client # type: ignore[import-untyped] except ImportError: LOGGER.info("ppt_link_refresh_skipped file=%s reason=win32com_unavailable", pptx_path) return PptProcessingResult( From 7113dde4106f2772dc7e140ec10e0ce8c95d5a99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:30:42 +0000 Subject: [PATCH 15/15] chore(codex-keepalive): apply updates (PR #249) --- tests/test_mapping_diff_report_cli.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_mapping_diff_report_cli.py b/tests/test_mapping_diff_report_cli.py index 47eebbe8..f2988796 100644 --- a/tests/test_mapping_diff_report_cli.py +++ b/tests/test_mapping_diff_report_cli.py @@ -44,6 +44,20 @@ def test_mapping_diff_report_help_exits_zero() -> None: assert "mapping_diff_report" in result.stdout +def test_mapping_diff_report_with_repo_registry_exits_zero() -> None: + result = subprocess.run( + [*_cli_cmd(), "--registry", "config/name_registry.yml"], + check=False, + capture_output=True, + text=True, + env=_cli_env(), + ) + assert result.returncode == 0 + assert "UNMAPPED" in result.stdout + assert "FALLBACK_MAPPED" in result.stdout + assert "SUGGESTIONS" in result.stdout + + def test_mapping_diff_report_missing_registry_exits_nonzero(tmp_path: Path) -> None: missing_registry = tmp_path / "missing_registry.yml" result = subprocess.run(