Three Phase Q modules each grew their own catalog loader (and two of them grew their own _get_block_by_id) during Phase Q evolution. INSIGHT-MAP §5 K5 (docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151) and PHASE-Q-AUDIT.md §2.10 K5 (docs/architecture/PHASE-Q-AUDIT.md:1087) catch this as a cleanup-only 별 axis ("Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 중복 제거 별도 판단"). Verified factually per RULE 5 (value + path + upstream + unknown):
1-a. Three loaders, not three duplicates — shapes differ
The renderer pair returns a derived projection (id → template-path string), not the catalog list. Only block_reference._load_catalog and block_selector.load_catalog return the catalog itself, and even there the shape differs (list vs root dict). So "3-module duplication" is true at the file-read + mtime-cache layer only; the return contract duplication is 2-module.
src/renderer.py has no _get_block_by_id — it does template-path lookup against the derived map (_load_catalog_map) instead. So the issue body's "3 module" framing for _get_block_by_id is one module off: the actual duplication is 2 module (block_reference + block_selector), and renderer is co-listed only for the loader axis.
1-c. Critical contextual fact — templates/catalog.yaml was deleted on 2026-05-08
Commit cc2f434 ("cleanup: legacy templates/blocks + figma_to_html_agent block-tests / 옛 docs 정리", 2026-05-08) deleted templates/catalog.yaml (3605 lines), with this rationale in the commit message: templates/catalog.yaml — 위 block library 의 catalog (의존성과 같이 폐기). ... Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증).
Verified:
ls templates/catalog.yaml → not found (current main HEAD = 2896bb6).
git log -- templates/catalog.yaml shows last touch = cc2f434 (deletion).
block_reference._load_catalog (L113): unconditional path.stat().st_mtime — FileNotFoundError on first invocation. No .exists() guard.
The third loader (block_reference) is the de-facto-shared utility (called by fit_verifier ×4, space_allocator ×3, pipeline ×1), so an actual runtime call against the dead path would raise. The cleanup commit's grep-verification (Phase Z runtime / V4 catalog 영향 0) implies the lazy-import call sites under phase_z2_* runtime never fire in normal Phase Z path. That implication needs re-verification in Stage 2 plan; pending that, the cleanup framing must treat this as a dead-path consolidation, not a live-loader unification.
1-d. Trigger-axis status (soft link IMP-04)
Issue trigger reads "Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04)". Verified:
IMP-04 status in backlog (docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48) = implemented. ✓ trigger #2 met on paper.
BUT IMP-04's frame_contracts.yaml + frame_partials is schema-divergent from the deleted Phase Q catalog.yaml (block id + variant) — established by IMP-24 (block_reference.py / block_selector.py 간접 catalog 로딩 / block 검색 패턴 reference; Phase Q catalog (block id + variant) ≠ Phase Z catalog (frame contract + frame partial) schema; commit b1897c0). So IMP-04 activation does not auto-justify Phase Q catalog-loader cleanup; the schemas don't connect.
Phase R' carve-out (IMP-17) = documented (deferred) (docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68); activation gate (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live) is incomplete — B4 frame_selection evidence integration is pending (CLAUDE.md "B4 frame_selection evidence integration pending"). Phase R' is quiescent, not retired.
→ Strict reading: neither activation gate is fully met. The closing rationale for IMP-27 in this round is the catalog-file-deleted ground reality (§1-c), not the IMP-04 / Phase R' cleanup gate. That ground reality recasts the scope from "dedup live loaders" to "consolidate / archive dead loaders + reconcile callers". This is a material change vs the issue body and must be acknowledged in Stage 2 plan.
1-e. Caller surface in / out of scope
In K5's 3-module scope but with cross-module caller links:
block_reference._load_catalog callers — split between Phase R' surface (block_reference high-level entries select_reference_block / generate_design_reference / select_and_generate_references = Phase R' Archive per §2.10 K1, docs/architecture/PHASE-Q-AUDIT.md:1079) and Phase Q residual-utility surface (fit_verifier._load_catalog 4×, space_allocator._load_catalog 3×, pipeline._match_by_tags at L228). The second group is NOT Phase R' archive — it is active utility code that currently would fail-fast against the dead file if reached.
block_selector.load_catalog callers — internal to block_selector only. block_selector itself is unused in pipeline.py (Phase Q-2 legacy per §2.10 K3, docs/architecture/PHASE-Q-AUDIT.md:1083); only format_candidates_for_prompt is consumed by src/kei_client.py:454 (Kei flow integration, Archive per §2.10 K2).
renderer._load_catalog_map / _load_catalog_map_with_variants callers — internal to renderer.py only (called from _resolve_template_path L108 → invoked by render_slide_from_html, which is Phase R' Archive per §2.8 audit docs/architecture/PHASE-Q-AUDIT.md:1345 "AI 가 HTML 구조 직접 생성 금지").
→ Asymmetric surface: cleanup of renderer._load_catalog_map / block_selector.load_catalog touches Archive surface only; cleanup of block_reference._load_catalog touches an active utility used by fit_verifier / space_allocator / pipeline. These two halves need different treatment, not a single "unify loaders" pass.
2. Scope-lock proposal (Stage 1 — to be tightened in Stage 2)
phase_z2_* modules (independent Phase Z catalog path: templates/phase_z2/catalog/frame_contracts.yaml)
schema
catalog SCHEMA unchanged (issue guardrail: "catalog 변경 0 — 로드 path 만 통합")
NB: file templates/catalog.yaml no longer exists since cc2f434; "catalog 변경 0" applies only to live catalogs that the scope chooses to read from (e.g., templates/catalog/blocks.yaml if and only if Stage 2 explicitly chooses that path — undecided)
edit nature
load-path consolidation across the 3 modules; reconcile diverged signatures (list[dict] vs root-dict vs id→template_path); align _get_block_by_id between block_reference + block_selector
NO new loader feature (no schema migration, no template_partial registration, no Phase Z merge)
trigger
acknowledged: full activation gate unmet (see §1-d); proceed only on user GO given §1-c ground reality
NOT auto-justified by IMP-04 alone
Out of scope (must remain untouched):
templates/phase_z2/catalog/frame_contracts.yaml and Phase Z partials (IMP-04 axis — different schema).
templates/catalog/blocks.yaml (Phase Y/Q' block catalog read by src/catalog_blocks.py / src/pipeline_v2.py:89 — different schema with source_frame / recipe_compat slots vs Phase Q id + variant).
Other inline yaml.safe_load(catalog_path) sites in block_assembler.py, design_director.py, block_matcher_tfidf.py, block_search.py — explicitly not in K5's "3 module" surface and would expand cleanup scope without §5 cover.
Test surface (tests/test_catalog_invariant.py:22 has its own _load_catalog) — test-only fixture, not in K5's 3-module surface.
3. Guardrails / validation
No new behavior — load-path consolidation only. No new feature, no schema change, no new caller. Issue guardrail "catalog 변경 0 — 로드 path 만 통합" preserved at the abstraction layer (if Stage 2 chooses to point a unified loader at templates/catalog/blocks.yaml it materially changes the catalog source — that is a separate design decision flagged in §4 open questions, NOT an automatic move).
Return-shape compatibility for active callers — fit_verifier / space_allocator / pipeline.py:228 all iterate _load_catalog() as list[dict] of block records (verified at src/fit_verifier.py:132, src/space_allocator.py:37,392,713,787,825, src/pipeline.py:228+). Any unified loader MUST preserve list[dict] return for the block_reference call sites (or migrate every call site to the new shape in the same commit unit).
Phase R' isolation preserved — renderer._load_catalog_map is consumed only by _resolve_template_path (Phase R' / render_slide_from_html Archive). Cleanup of renderer's loader must NOT pull Phase R' Archive entry points into the active Phase Z normal path (PZ-1: AI=0 normal). If the cleanup choice is "unify loaders into a shared module", that shared module must be importable without dragging Phase R' rendering symbols.
block_selector legacy status preserved — §2.10 K3 (docs/architecture/PHASE-Q-AUDIT.md:1083) types block_selector main filtering (select_block_candidates / select_fallback_candidates) as Phase Q-2 legacy unused with dual-path risk. K5 cleanup must NOT silently reactivate that path (e.g., by routing block_reference callers through block_selector.load_catalog's dict-shape).
Schema-divergence preserved (IMP-24 lock) — Phase Q catalog (block id + variant) ≠ Phase Z catalog (frame contract + frame partial). Unified loader MUST NOT bridge to templates/phase_z2/catalog/frame_contracts.yaml. Verified at docs/architecture/PHASE-Q-AUDIT.md:99 (post-IMP-24 commit b1897c0 annotated form).
Deleted-file precondition surfaced — Stage 2 plan MUST explicitly choose one of (a) remove dead loaders + dead callers entirely, (b) migrate the unified loader to a live catalog source (templates/catalog/blocks.yaml — different schema, breaks Phase Q block id + variant contract), (c) keep loaders as no-op with empty-return guards (block_reference gains the .exists() guard that block_selector / renderer already have). Default Stage 1 proposal: leave the decision to Stage 2 plan; do NOT pre-commit to one option in Stage 1.
Pipeline-construction RULE 0 — cleanup is not sample-passing; verification axis must show the consolidated loader survives whatever the chosen catalog source is across all 32 frame catalog rows (or, if option (a) is chosen, that all removed callers are confirmed dead in Phase Z normal path).
value: templates/catalog.yaml absent — verified ls templates/catalog.yaml returns no entry; only templates/catalog/blocks.yaml and templates/phase_z2/catalog/frame_contracts.yaml exist (find templates -name "*.yaml").
upstream: deletion commit = cc2f434 (2026-05-08); commit message verified to state "Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증)".
value: K5 catch text = "K5. (cleanup 별 axis 후보) — catalog 로드 + _get_block_by_id 중복 (block_reference / block_selector / §2.8 renderer 3 module). Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 중복 제거 별도 판단." — verified at docs/architecture/PHASE-Q-AUDIT.md:1087.
upstream: INSIGHT-MAP §5 K5 row at docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151; backlog IMP-27 row at docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94 (status=pending, priority=↓ low (future), soft link IMP-04).
upstream: IMP-04 status implemented verified at docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48.
upstream: IMP-17 status documented (deferred) verified at docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68; 3-cond AND gate with "B4 frame_selection evidence" pending (CLAUDE.md confirms "B4 frame_selection evidence integration pending").
unknown surface (Stage 2 must close): does any phase_z2_*.py lazy-import block_reference._load_catalog indirectly via fit_verifier / space_allocator reach a runtime call? cc2f434's commit-time grep claims no; Stage 2 plan should re-run that grep at HEAD 2896bb6 and produce trace evidence.
unknown surface (Stage 2 must close): Phase Y templates/catalog/blocks.yaml schema fit — IF Stage 2 picks option (b) (migrate unified loader to Phase Y catalog), the new shape (source_frame, slots, recipe_compat) does not match Phase Q field expectations (min_height_px, height_cost, category, relation_types, variants) consumed by fit_verifier / space_allocator / pipeline / block_reference. Option (b) likely breaks active utility callers. Recommend Stage 2 default to option (a) or (c).
4. Open questions for Stage 2 plan
Catalog source after cleanup — option (a) remove, (b) repoint to templates/catalog/blocks.yaml with adapter, or (c) no-op guards. Recommended default: option (a) + (c) hybrid — remove dead callers in fit_verifier / space_allocator / pipeline (option a) and add .exists() guard to block_reference._load_catalog for any residual lazy imports (option c). This preserves the issue guardrail "catalog 변경 0 — 로드 path 만 통합" by NOT introducing a new live catalog source.
_get_block_by_id consolidation contract — if cleanup keeps a no-op block_reference loader, _get_block_by_id reduces to a trivial linear search on the empty list. Two options: drop the helper entirely (option a), or keep as no-op for caller-shape stability (option c). Tied to Q1.
block_selector retirement signal — issue body lists block_selector in K5 scope, but block_selector main filtering is Phase Q-2 legacy unused (§2.10 K3) and only format_candidates_for_prompt is consumed by kei_client.py:454 (Kei flow integration Archive per K2). Stage 2 must decide: do we (i) remove block_selector.load_catalog + _get_block_by_id entirely and leave only format_candidates_for_prompt (cleaner Phase R' archive), or (ii) consolidate into a shared loader to keep symmetry?
Renderer template-path map cleanup — renderer._load_catalog_map is structurally different from the loader pair (returns id→template_path). Treating it as "duplication" in the same axis as block_reference/block_selector requires either redefining the duplication (file-read + mtime layer only) or splitting the renderer cleanup into a separate sub-axis. Stage 2 should pick one framing.
Test coverage — tests/test_catalog_invariant.py:22 defines its own local _load_catalog. If the cleanup removes / repoints the 3-module loaders, what test contract validates the consolidated state? Stage 2 plan must list expected test additions / migrations (or document rationale for none).
5. Stage 2 plan preview (not executed in Stage 1)
Re-run caller-trace grep at HEAD 2896bb6 to confirm Phase Z normal path does not invoke any of the 3 loaders (close §3 unknown #1).
Pick one of options (a)/(b)/(c) per §4 Q1 + Q2 with justification.
Lay out implementation_units (likely 3–4: block_reference, block_selector, renderer, optional shared module under src/ or unit test additions).
List concrete tests (pytest -q paths) including any new tests/test_catalog_consolidation_*.py.
Confirm commit unit boundary (RULE: 1 commit = 1 decision unit) — likely one commit per module, sequenced block_selector → renderer → block_reference (smallest archive surface first, largest active-caller surface last).
6. Review loop posture
This stage produces a scope-lock proposal + open-question surface only. No code edits made.
Material divergence from the issue body flagged in §1-c (catalog file deleted, not present): this is NOT a "dedup 3 live loaders" cleanup but a "consolidate 3 dead-or-degenerate loaders + reconcile callers" cleanup. Codex review must adjudicate whether to (i) re-scope IMP-27 accordingly under this Stage 1 problem-review, or (ii) rewind for re-scope in a separate axis / new issue. Default recommendation: accept the re-scope in Stage 1 with the proposed scope-lock + guardrails.
Awaiting Codex review for FINAL_CONSENSUS: YES/NO before proceeding to Stage 2 plan.
If FINAL_CONSENSUS: NO, expected rewind_target candidates:
stage_1_review — if scope-lock / guardrails need factual or boundary refinement (e.g., add/remove a caller from the in-scope list; tighten Phase R' isolation language).
(No earlier stage exists for Stage 1.)
[Claude #1] Stage 1 problem-review — IMP-27 K5 (catalog 로드 + `_get_block_by_id` 중복 cleanup, 3 module)
## 1. Root cause
Three Phase Q modules each grew their own catalog loader (and two of them grew their own `_get_block_by_id`) during Phase Q evolution. INSIGHT-MAP §5 K5 (`docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151`) and PHASE-Q-AUDIT.md §2.10 K5 (`docs/architecture/PHASE-Q-AUDIT.md:1087`) catch this as a cleanup-only 별 axis ("Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 *중복 제거 별도 판단*"). Verified factually per RULE 5 (value + path + upstream + unknown):
### 1-a. Three loaders, **not three duplicates** — shapes differ
| module | symbol | return type | callers |
|---|---|---|---|
| `src/block_reference.py:110` | `_load_catalog() -> list[dict]` | top-level `blocks` list | `src/block_reference.py` (5×: L260, L324, L462, L657, L666, L713), `src/fit_verifier.py` (4×: L131, L840, L985, L1004), `src/space_allocator.py` (3×: L34, L390, L786), `src/pipeline.py:228` |
| `src/block_selector.py:57` | `load_catalog() -> dict` | full root dict (`{"blocks": [...], ...}`) | internal only — `src/block_selector.py` L109, L203 |
| `src/renderer.py:32` | `_load_catalog_map() -> dict[str, str]` | derived `id → template_path` map | `src/renderer.py` L77, L126, L135 |
| `src/renderer.py:69` | `_load_catalog_map_with_variants() -> dict[str, str]` | derived `"id--variant" → template_path` map | `src/renderer.py` L126 |
The renderer pair returns a **derived projection** (id → template-path string), not the catalog list. Only `block_reference._load_catalog` and `block_selector.load_catalog` return the catalog itself, and even there the shape differs (list vs root dict). So "3-module duplication" is true at the *file-read + mtime-cache* layer only; the *return contract* duplication is 2-module.
### 1-b. Two `_get_block_by_id`, **different signatures**
| module | signature | catalog source |
|---|---|---|
| `src/block_reference.py:124` | `_get_block_by_id(block_id: str) -> dict \| None` | internal `_load_catalog()` call |
| `src/block_selector.py:78` | `_get_block_by_id(block_id: str, catalog: dict) -> dict \| None` | caller-supplied |
`src/renderer.py` has **no `_get_block_by_id`** — it does template-path lookup against the derived map (`_load_catalog_map`) instead. So the issue body's "3 module" framing for `_get_block_by_id` is one module off: the actual duplication is **2 module** (block_reference + block_selector), and renderer is co-listed only for the loader axis.
### 1-c. **Critical contextual fact — `templates/catalog.yaml` was deleted on 2026-05-08**
Commit `cc2f434` ("cleanup: legacy templates/blocks + figma_to_html_agent block-tests / 옛 docs 정리", 2026-05-08) deleted `templates/catalog.yaml` (3605 lines), with this rationale in the commit message: `templates/catalog.yaml — 위 block library 의 catalog (의존성과 같이 폐기). ... Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증)`.
Verified:
- `ls templates/catalog.yaml` → not found (current main HEAD = `2896bb6`).
- `git log -- templates/catalog.yaml` shows last touch = `cc2f434` (deletion).
- All three modules still reference the dead path:
- `src/block_reference.py:112` → `path = TEMPLATES_DIR / "catalog.yaml"`
- `src/block_selector.py:22` → `CATALOG_PATH = Path("templates/catalog.yaml")`
- `src/renderer.py:23` → `CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"`
Failure-mode mismatch among the three loaders against the deleted file:
- `block_selector.load_catalog` (L61–63): `if not CATALOG_PATH.exists(): return {"blocks": []}` — graceful empty.
- `renderer._load_catalog_map` (L39, L47, L60): `if CATALOG_PATH.exists()` guarded + warning log — graceful empty.
- `block_reference._load_catalog` (L113): unconditional `path.stat().st_mtime` — **FileNotFoundError** on first invocation. No `.exists()` guard.
The third loader (block_reference) is the de-facto-shared utility (called by fit_verifier ×4, space_allocator ×3, pipeline ×1), so an actual runtime call against the dead path would raise. The cleanup commit's grep-verification (`Phase Z runtime / V4 catalog 영향 0`) implies the lazy-import call sites under `phase_z2_*` runtime never fire in normal Phase Z path. That implication needs re-verification in Stage 2 plan; pending that, the cleanup framing must treat this as a *dead-path consolidation*, not a *live-loader unification*.
### 1-d. Trigger-axis status (soft link IMP-04)
Issue trigger reads "Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04)". Verified:
- IMP-04 status in backlog (`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48`) = **implemented**. ✓ trigger #2 met on paper.
- BUT IMP-04's `frame_contracts.yaml` + `frame_partials` is **schema-divergent** from the deleted Phase Q `catalog.yaml` (block id + variant) — established by IMP-24 (`block_reference.py` / `block_selector.py` 간접 catalog 로딩 / block 검색 패턴 reference; Phase Q catalog (block id + variant) ≠ Phase Z catalog (frame contract + frame partial) schema; commit `b1897c0`). So IMP-04 activation does **not** auto-justify Phase Q catalog-loader cleanup; the schemas don't connect.
- Phase R' carve-out (IMP-17) = `documented (deferred)` (`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68`); activation gate (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live) is incomplete — B4 frame_selection evidence integration is **pending** (CLAUDE.md "B4 frame_selection evidence integration pending"). Phase R' is quiescent, not retired.
→ Strict reading: neither activation gate is fully met. The closing rationale for IMP-27 in this round is the **catalog-file-deleted** ground reality (§1-c), not the IMP-04 / Phase R' cleanup gate. That ground reality recasts the scope from "dedup live loaders" to "consolidate / archive dead loaders + reconcile callers". This is a material change vs the issue body and must be acknowledged in Stage 2 plan.
### 1-e. Caller surface in / out of scope
In K5's 3-module scope but with cross-module caller links:
- `block_reference._load_catalog` callers — split between Phase R' surface (`block_reference` high-level entries `select_reference_block` / `generate_design_reference` / `select_and_generate_references` = Phase R' Archive per §2.10 K1, `docs/architecture/PHASE-Q-AUDIT.md:1079`) and **Phase Q residual-utility surface** (`fit_verifier._load_catalog` 4×, `space_allocator._load_catalog` 3×, `pipeline._match_by_tags` at L228). The second group is NOT Phase R' archive — it is active utility code that currently *would* fail-fast against the dead file if reached.
- `block_selector.load_catalog` callers — internal to `block_selector` only. `block_selector` itself is **unused in pipeline.py** (Phase Q-2 legacy per §2.10 K3, `docs/architecture/PHASE-Q-AUDIT.md:1083`); only `format_candidates_for_prompt` is consumed by `src/kei_client.py:454` (Kei flow integration, Archive per §2.10 K2).
- `renderer._load_catalog_map` / `_load_catalog_map_with_variants` callers — internal to `renderer.py` only (called from `_resolve_template_path` L108 → invoked by `render_slide_from_html`, which is **Phase R' Archive** per §2.8 audit `docs/architecture/PHASE-Q-AUDIT.md:1345` "AI 가 HTML 구조 직접 생성 금지").
→ **Asymmetric surface**: cleanup of `renderer._load_catalog_map` / `block_selector.load_catalog` touches Archive surface only; cleanup of `block_reference._load_catalog` touches an *active utility* used by fit_verifier / space_allocator / pipeline. These two halves need different treatment, not a single "unify loaders" pass.
## 2. Scope-lock proposal (Stage 1 — to be tightened in Stage 2)
| axis | included | excluded |
|---|---|---|
| files (source) | `src/block_reference.py` (loader + id-lookup blocks L107–129), `src/block_selector.py` (CATALOG_PATH + loader + id-lookup L22–84), `src/renderer.py` (CATALOG_PATH + 2 loaders L23–97) | any non-K5 catalog-load site (block_assembler.py L384/L916/L997, design_director.py L417/L432/L871/L885, block_matcher_tfidf.py L33/L55, block_search.py L27, catalog_blocks.py) |
| files (callers) | `src/fit_verifier.py` (4 import sites: L131, L840, L985, L1004), `src/space_allocator.py` (3 import sites: L34, L390, L786), `src/pipeline.py:228`, `src/kei_client.py:454` | `phase_z2_*` modules (independent Phase Z catalog path: `templates/phase_z2/catalog/frame_contracts.yaml`) |
| schema | catalog SCHEMA unchanged (issue guardrail: "catalog 변경 0 — 로드 path 만 통합") | **NB**: file `templates/catalog.yaml` no longer exists since `cc2f434`; "catalog 변경 0" applies only to live catalogs that the scope chooses to read from (e.g., `templates/catalog/blocks.yaml` if and only if Stage 2 explicitly chooses that path — undecided) |
| edit nature | load-path consolidation across the 3 modules; reconcile diverged signatures (`list[dict]` vs root-`dict` vs `id→template_path`); align `_get_block_by_id` between block_reference + block_selector | NO new loader feature (no schema migration, no template_partial registration, no Phase Z merge) |
| trigger | acknowledged: full activation gate unmet (see §1-d); proceed only on user GO given §1-c ground reality | NOT auto-justified by IMP-04 alone |
**Out of scope (must remain untouched):**
- `templates/phase_z2/catalog/frame_contracts.yaml` and Phase Z partials (IMP-04 axis — different schema).
- `templates/catalog/blocks.yaml` (Phase Y/Q' block catalog read by `src/catalog_blocks.py` / `src/pipeline_v2.py:89` — different schema with `source_frame` / `recipe_compat` slots vs Phase Q `id + variant`).
- Phase R' high-level entries (`select_reference_block` / `generate_design_reference` / `select_and_generate_references` / `format_candidates_for_prompt`) — Archive markers K1 / K2, no cleanup posture.
- Other inline `yaml.safe_load(catalog_path)` sites in `block_assembler.py`, `design_director.py`, `block_matcher_tfidf.py`, `block_search.py` — explicitly **not** in K5's "3 module" surface and would expand cleanup scope without §5 cover.
- Test surface (`tests/test_catalog_invariant.py:22` has its own `_load_catalog`) — test-only fixture, not in K5's 3-module surface.
## 3. Guardrails / validation
1. **No new behavior** — load-path consolidation only. No new feature, no schema change, no new caller. Issue guardrail "catalog 변경 0 — 로드 path 만 통합" preserved at the abstraction layer (if Stage 2 chooses to point a unified loader at `templates/catalog/blocks.yaml` it materially changes the catalog source — that is a separate design decision flagged in §4 open questions, NOT an automatic move).
2. **Return-shape compatibility for active callers** — `fit_verifier` / `space_allocator` / `pipeline.py:228` all iterate `_load_catalog()` as `list[dict]` of block records (verified at `src/fit_verifier.py:132`, `src/space_allocator.py:37,392,713,787,825`, `src/pipeline.py:228+`). Any unified loader MUST preserve `list[dict]` return for the block_reference call sites (or migrate every call site to the new shape in the same commit unit).
3. **Phase R' isolation preserved** — `renderer._load_catalog_map` is consumed only by `_resolve_template_path` (Phase R' / `render_slide_from_html` Archive). Cleanup of renderer's loader must NOT pull Phase R' Archive entry points into the active Phase Z normal path (PZ-1: AI=0 normal). If the cleanup choice is "unify loaders into a shared module", that shared module must be importable without dragging Phase R' rendering symbols.
4. **`block_selector` legacy status preserved** — §2.10 K3 (`docs/architecture/PHASE-Q-AUDIT.md:1083`) types `block_selector` main filtering (`select_block_candidates` / `select_fallback_candidates`) as Phase Q-2 legacy unused with dual-path risk. K5 cleanup must NOT silently reactivate that path (e.g., by routing block_reference callers through `block_selector.load_catalog`'s dict-shape).
5. **Schema-divergence preserved (IMP-24 lock)** — Phase Q catalog (block id + variant) ≠ Phase Z catalog (frame contract + frame partial). Unified loader MUST NOT bridge to `templates/phase_z2/catalog/frame_contracts.yaml`. Verified at `docs/architecture/PHASE-Q-AUDIT.md:99` (post-IMP-24 commit `b1897c0` annotated form).
6. **Deleted-file precondition surfaced** — Stage 2 plan MUST explicitly choose one of (a) remove dead loaders + dead callers entirely, (b) migrate the unified loader to a live catalog source (`templates/catalog/blocks.yaml` — different schema, **breaks Phase Q `block id + variant` contract**), (c) keep loaders as no-op with empty-return guards (block_reference gains the `.exists()` guard that block_selector / renderer already have). Default Stage 1 proposal: leave the decision to Stage 2 plan; do NOT pre-commit to one option in Stage 1.
7. **Pipeline-construction RULE 0** — cleanup is *not* sample-passing; verification axis must show the consolidated loader survives whatever the chosen catalog source is across all 32 frame catalog rows (or, if option (a) is chosen, that all removed callers are confirmed dead in Phase Z normal path).
8. **Factual verification (RULE 5: value + path + upstream + unknown surface):**
- value: `templates/catalog.yaml` absent — verified `ls templates/catalog.yaml` returns no entry; only `templates/catalog/blocks.yaml` and `templates/phase_z2/catalog/frame_contracts.yaml` exist (`find templates -name "*.yaml"`).
- upstream: deletion commit = `cc2f434` (2026-05-08); commit message verified to state "Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증)".
- value: K5 catch text = "K5. (cleanup 별 axis 후보) — catalog 로드 + `_get_block_by_id` 중복 (block_reference / block_selector / §2.8 renderer 3 module). Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 *중복 제거 별도 판단*." — verified at `docs/architecture/PHASE-Q-AUDIT.md:1087`.
- upstream: INSIGHT-MAP §5 K5 row at `docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151`; backlog IMP-27 row at `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94` (status=pending, priority=↓ low (future), soft link IMP-04).
- upstream: IMP-04 status `implemented` verified at `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48`.
- upstream: IMP-17 status `documented (deferred)` verified at `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68`; 3-cond AND gate with "B4 frame_selection evidence" pending (CLAUDE.md confirms "B4 frame_selection evidence integration pending").
- value: caller counts — `Grep _load_catalog\|load_catalog\|_get_block_by_id` across `src/` (head_limit 0): block_reference `_load_catalog` 6 internal + 4 external (fit_verifier) + 3 external (space_allocator) + 1 external (pipeline) = 14 invocations of the same symbol; block_selector `load_catalog` 2 internal + 0 external; renderer `_load_catalog_map` 3 internal + 0 external.
- unknown surface (Stage 2 must close): does any `phase_z2_*.py` lazy-import `block_reference._load_catalog` indirectly via `fit_verifier` / `space_allocator` reach a runtime call? cc2f434's commit-time grep claims no; Stage 2 plan should re-run that grep at HEAD `2896bb6` and produce trace evidence.
- unknown surface (Stage 2 must close): Phase Y `templates/catalog/blocks.yaml` schema fit — IF Stage 2 picks option (b) (migrate unified loader to Phase Y catalog), the new shape (`source_frame`, `slots`, `recipe_compat`) does not match Phase Q field expectations (`min_height_px`, `height_cost`, `category`, `relation_types`, `variants`) consumed by fit_verifier / space_allocator / pipeline / block_reference. Option (b) likely breaks active utility callers. Recommend Stage 2 default to option (a) or (c).
## 4. Open questions for Stage 2 plan
1. **Catalog source after cleanup** — option (a) remove, (b) repoint to `templates/catalog/blocks.yaml` with adapter, or (c) no-op guards. Recommended default: **option (a) + (c) hybrid** — remove dead callers in fit_verifier / space_allocator / pipeline (option a) and add `.exists()` guard to block_reference._load_catalog for any residual lazy imports (option c). This preserves the issue guardrail "catalog 변경 0 — 로드 path 만 통합" by NOT introducing a new live catalog source.
2. **`_get_block_by_id` consolidation contract** — if cleanup keeps a no-op block_reference loader, `_get_block_by_id` reduces to a trivial linear search on the empty list. Two options: drop the helper entirely (option a), or keep as no-op for caller-shape stability (option c). Tied to Q1.
3. **`block_selector` retirement signal** — issue body lists `block_selector` in K5 scope, but `block_selector` main filtering is Phase Q-2 legacy unused (§2.10 K3) and only `format_candidates_for_prompt` is consumed by `kei_client.py:454` (Kei flow integration Archive per K2). Stage 2 must decide: do we (i) remove `block_selector.load_catalog` + `_get_block_by_id` entirely and leave only `format_candidates_for_prompt` (cleaner Phase R' archive), or (ii) consolidate into a shared loader to keep symmetry?
4. **Renderer template-path map cleanup** — `renderer._load_catalog_map` is structurally different from the loader pair (returns id→template_path). Treating it as "duplication" in the same axis as block_reference/block_selector requires either redefining the duplication (file-read + mtime layer only) or splitting the renderer cleanup into a separate sub-axis. Stage 2 should pick one framing.
5. **Test coverage** — `tests/test_catalog_invariant.py:22` defines its own local `_load_catalog`. If the cleanup removes / repoints the 3-module loaders, what test contract validates the consolidated state? Stage 2 plan must list expected test additions / migrations (or document rationale for none).
## 5. Stage 2 plan preview (not executed in Stage 1)
- Re-run caller-trace grep at HEAD `2896bb6` to confirm Phase Z normal path does not invoke any of the 3 loaders (close §3 unknown #1).
- Pick one of options (a)/(b)/(c) per §4 Q1 + Q2 with justification.
- Lay out implementation_units (likely 3–4: block_reference, block_selector, renderer, optional shared module under `src/` or unit test additions).
- List concrete tests (pytest -q paths) including any new `tests/test_catalog_consolidation_*.py`.
- Confirm commit unit boundary (RULE: 1 commit = 1 decision unit) — likely one commit per module, sequenced block_selector → renderer → block_reference (smallest archive surface first, largest active-caller surface last).
## 6. Review loop posture
- This stage produces a scope-lock proposal + open-question surface only. No code edits made.
- **Material divergence from the issue body** flagged in §1-c (catalog file deleted, not present): this is NOT a "dedup 3 live loaders" cleanup but a "consolidate 3 dead-or-degenerate loaders + reconcile callers" cleanup. Codex review must adjudicate whether to (i) re-scope IMP-27 accordingly under this Stage 1 problem-review, or (ii) rewind for re-scope in a separate axis / new issue. Default recommendation: accept the re-scope in Stage 1 with the proposed scope-lock + guardrails.
- Awaiting Codex review for `FINAL_CONSENSUS: YES/NO` before proceeding to Stage 2 plan.
- If FINAL_CONSENSUS: NO, expected `rewind_target` candidates:
- `stage_1_review` — if scope-lock / guardrails need factual or boundary refinement (e.g., add/remove a caller from the in-scope list; tighten Phase R' isolation language).
- (No earlier stage exists for Stage 1.)
I verified the root cause with grep/find against the current worktree. The IMP-27 cleanup target is real, but it must be worded precisely:
The scoped 3-module duplication is the catalog read/cache/projection layer, not identical return contracts.
src/block_reference.py:110 defines _load_catalog() -> list[dict], reads templates/catalog.yaml, caches by mtime, and returns data["blocks"].
src/block_selector.py:57 defines load_catalog() -> dict, reads templates/catalog.yaml, caches by mtime, and returns the full root dict.
src/renderer.py:32 defines _load_catalog_map() -> dict[str, str], reads templates/catalog.yaml, caches by mtime, and returns an id-to-template projection.
src/renderer.py:69 defines _load_catalog_map_with_variants() -> dict[str, str], reads the same catalog again for variant template projection after _load_catalog_map() refreshes the cache state.
_get_block_by_id duplication is 2-module only.
src/block_reference.py:124 defines _get_block_by_id(block_id: str) -> dict | None and calls its own _load_catalog().
src/block_selector.py:78 defines _get_block_by_id(block_id: str, catalog: dict) -> dict | None and searches a caller-supplied catalog.
src/renderer.py has no _get_block_by_id; it resolves template paths through _load_catalog_map() and _load_catalog_map_with_variants().
Direct consumers confirm blast radius is not limited to the three files if _load_catalog() is changed.
src/block_reference.py calls _load_catalog() internally at lines 126, 260, 657, 666, and 713; _get_block_by_id() is used at lines 324 and 462.
src/fit_verifier.py imports src.block_reference._load_catalog at lines 131, 840, 985, and 1004.
src/space_allocator.py imports src.block_reference._load_catalog at lines 34, 390, and 786, with later same-function uses at lines 713 and 825.
src/pipeline.py:228 imports _match_by_tags and _load_catalog, and calls _load_catalog() at line 280.
Current catalog-path fact: templates/catalog.yaml is missing in the current worktree.
Test-Path templates/catalog.yaml returned False.
git log --diff-filter=D --summary -- templates/catalog.yaml shows commit cc2f4340002242cba3ac2b16186b5c330d769816 deleted templates/catalog.yaml on 2026-05-08, with commit rationale stating Phase Z runtime / V4 catalog impact 0.
Current catalog-adjacent paths include templates/catalog/blocks.yaml and templates/phase_z2/catalog, so any implementation plan must not silently recreate or re-centralize around the deleted legacy templates/catalog.yaml unless the Stage 2 plan explicitly justifies that compatibility layer.
Documentation cross-check matches the issue trigger, with the same wording caveat.
docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151 lists K5 as catalog load plus _get_block_by_id duplication across block_reference, block_selector, and renderer, triggered only by Phase R' cleanup or Phase Z catalog extension.
docs/architecture/PHASE-Q-AUDIT.md:1087 lists the same K5 cleanup axis.
docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94 lists IMP-27 as pending low-priority future cleanup, scoped to duplicate catalog loading across block_reference, block_selector, and renderer.
Scope lock for Stage 2:
In scope: design a cleanup path for the three named modules' duplicate catalog access/projection behavior, while preserving each current return contract or explicitly adapting all direct callers.
In scope: decide whether the SoT is a Phase R' compatibility helper, a Phase Z catalog abstraction, or a no-op/defer because templates/catalog.yaml is deleted.
In scope: treat _get_block_by_id as a 2-module helper duplication, not a renderer concern.
Out of scope for this issue unless Stage 2 deliberately expands it: unrelated legacy templates/catalog.yaml readers found in other modules such as design_director.py, block_assembler.py, block_search.py, and block_matcher_tfidf.py.
Guardrail: do not hardcode sample IDs, do not revive MDX 03/04/05 assumptions, and do not change Phase Z V4 catalog semantics without a separate catalog-extension plan.
templates/phase_z2/catalog
Test results: Not run; Stage 1 explicitly says NO pytest.
Verified facts:
templates/catalog.yaml does not exist in the current worktree.
Commit cc2f4340002242cba3ac2b16186b5c330d769816 deleted templates/catalog.yaml on 2026-05-08.
Three scoped modules contain catalog load/projection code, but the returned shapes differ.
Only two scoped modules define _get_block_by_id; renderer does not.
_load_catalog() from block_reference.py has external callers in fit_verifier.py, space_allocator.py, and pipeline.py, so caller compatibility is a Stage 2 requirement.
FINAL_CONSENSUS: YES
[Codex #1] Stage 1 problem-review IMP-27
Verdict: scope-lock with correction.
I verified the root cause with grep/find against the current worktree. The IMP-27 cleanup target is real, but it must be worded precisely:
1. The scoped 3-module duplication is the catalog read/cache/projection layer, not identical return contracts.
- `src/block_reference.py:110` defines `_load_catalog() -> list[dict]`, reads `templates/catalog.yaml`, caches by mtime, and returns `data["blocks"]`.
- `src/block_selector.py:57` defines `load_catalog() -> dict`, reads `templates/catalog.yaml`, caches by mtime, and returns the full root dict.
- `src/renderer.py:32` defines `_load_catalog_map() -> dict[str, str]`, reads `templates/catalog.yaml`, caches by mtime, and returns an id-to-template projection.
- `src/renderer.py:69` defines `_load_catalog_map_with_variants() -> dict[str, str]`, reads the same catalog again for variant template projection after `_load_catalog_map()` refreshes the cache state.
2. `_get_block_by_id` duplication is 2-module only.
- `src/block_reference.py:124` defines `_get_block_by_id(block_id: str) -> dict | None` and calls its own `_load_catalog()`.
- `src/block_selector.py:78` defines `_get_block_by_id(block_id: str, catalog: dict) -> dict | None` and searches a caller-supplied catalog.
- `src/renderer.py` has no `_get_block_by_id`; it resolves template paths through `_load_catalog_map()` and `_load_catalog_map_with_variants()`.
3. Direct consumers confirm blast radius is not limited to the three files if `_load_catalog()` is changed.
- `src/block_reference.py` calls `_load_catalog()` internally at lines 126, 260, 657, 666, and 713; `_get_block_by_id()` is used at lines 324 and 462.
- `src/fit_verifier.py` imports `src.block_reference._load_catalog` at lines 131, 840, 985, and 1004.
- `src/space_allocator.py` imports `src.block_reference._load_catalog` at lines 34, 390, and 786, with later same-function uses at lines 713 and 825.
- `src/pipeline.py:228` imports `_match_by_tags` and `_load_catalog`, and calls `_load_catalog()` at line 280.
4. Current catalog-path fact: `templates/catalog.yaml` is missing in the current worktree.
- `Test-Path templates/catalog.yaml` returned `False`.
- `git log --diff-filter=D --summary -- templates/catalog.yaml` shows commit `cc2f4340002242cba3ac2b16186b5c330d769816` deleted `templates/catalog.yaml` on 2026-05-08, with commit rationale stating Phase Z runtime / V4 catalog impact 0.
- Current catalog-adjacent paths include `templates/catalog/blocks.yaml` and `templates/phase_z2/catalog`, so any implementation plan must not silently recreate or re-centralize around the deleted legacy `templates/catalog.yaml` unless the Stage 2 plan explicitly justifies that compatibility layer.
5. Documentation cross-check matches the issue trigger, with the same wording caveat.
- `docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151` lists K5 as catalog load plus `_get_block_by_id` duplication across `block_reference`, `block_selector`, and `renderer`, triggered only by Phase R' cleanup or Phase Z catalog extension.
- `docs/architecture/PHASE-Q-AUDIT.md:1087` lists the same K5 cleanup axis.
- `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94` lists IMP-27 as pending low-priority future cleanup, scoped to duplicate catalog loading across `block_reference`, `block_selector`, and `renderer`.
Scope lock for Stage 2:
- In scope: design a cleanup path for the three named modules' duplicate catalog access/projection behavior, while preserving each current return contract or explicitly adapting all direct callers.
- In scope: decide whether the SoT is a Phase R' compatibility helper, a Phase Z catalog abstraction, or a no-op/defer because `templates/catalog.yaml` is deleted.
- In scope: treat `_get_block_by_id` as a 2-module helper duplication, not a renderer concern.
- Out of scope for this issue unless Stage 2 deliberately expands it: unrelated legacy `templates/catalog.yaml` readers found in other modules such as `design_director.py`, `block_assembler.py`, `block_search.py`, and `block_matcher_tfidf.py`.
- Guardrail: do not hardcode sample IDs, do not revive MDX 03/04/05 assumptions, and do not change Phase Z V4 catalog semantics without a separate catalog-extension plan.
=== EVIDENCE ===
Commands run:
- `rg -n "def _load_catalog|def load_catalog|def _load_catalog_map|def _load_catalog_map_with_variants|def _get_block_by_id|catalog\\.yaml|_load_catalog\\(|load_catalog\\(|_load_catalog_map\\(" src docs templates`
- `Test-Path -LiteralPath 'templates\\catalog.yaml'; Get-ChildItem -LiteralPath 'templates' -Force`
- `git log --diff-filter=D --summary -- templates/catalog.yaml`
- `rg -n "from block_reference import|import block_reference|_load_catalog\\(|_get_block_by_id\\(|load_catalog\\(|_load_catalog_map\\(|_load_catalog_map_with_variants\\(" src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py`
- `Select-String -Path src/block_reference.py,src/block_selector.py,src/renderer.py -Pattern 'def _load_catalog|def load_catalog|def _get_block_by_id|def _load_catalog_map|def _load_catalog_map_with_variants|CATALOG_PATH|TEMPLATES_DIR' -Context 0,8`
- `rg -n "K5|_load_catalog|load_catalog|catalog schema|renderer|block_reference|block_selector|Phase R|Phase Z" docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md docs/architecture/PHASE-Q-AUDIT.md docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`
- `Select-String -Path src/fit_verifier.py,src/space_allocator.py,src/pipeline.py -Pattern 'from block_reference import|import block_reference|_load_catalog|_match_by_tags' -Context 0,2`
- `rg -n "_get_block_by_id" src tests docs --glob '!docs/history/**'`
- `rg -n "def _load_catalog\\(|def load_catalog\\(|def _load_catalog_map\\(|def _load_catalog_map_with_variants\\(" src --glob '!docs/history/**'`
- `rg -n "templates/catalog.yaml|catalog.yaml" tests src --glob '!docs/history/**'`
Files checked:
- `src/block_reference.py`
- `src/block_selector.py`
- `src/renderer.py`
- `src/fit_verifier.py`
- `src/space_allocator.py`
- `src/pipeline.py`
- `docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md`
- `docs/architecture/PHASE-Q-AUDIT.md`
- `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`
- `templates/catalog/blocks.yaml`
- `templates/phase_z2/catalog`
Test results: Not run; Stage 1 explicitly says NO pytest.
Verified facts:
- `templates/catalog.yaml` does not exist in the current worktree.
- Commit `cc2f4340002242cba3ac2b16186b5c330d769816` deleted `templates/catalog.yaml` on 2026-05-08.
- Three scoped modules contain catalog load/projection code, but the returned shapes differ.
- Only two scoped modules define `_get_block_by_id`; renderer does not.
- `_load_catalog()` from `block_reference.py` has external callers in `fit_verifier.py`, `space_allocator.py`, and `pipeline.py`, so caller compatibility is a Stage 2 requirement.
FINAL_CONSENSUS: YES
u3 — block_selector loader/helper → 1-line wrappers. [Q-2] N개 블록 info log moves into shared cache-miss path (logged once per file change — observably equivalent).
block_assembler.py (L384/L916/L997), block_search.py (L27), design_director.py (L417/L433/L872/L884), scripts/build_block_index.py L37, tests/test_catalog_invariant.py internal loader — NOT §scope (issue = 3 module only). Follow-up candidate "IMP-27b" deferred; not opened by this PR.
No change to templates/catalog.yaml, mtime caching policy beyond consolidation, Phase Z runtime, call-site signatures, return shapes.
tests/ grep: test_catalog_invariant.py has its own internal _load_catalog (not src import) → no test impact. scripts/build_block_index.py has its own load_catalog → out of scope.
Scope-qualified axis: catalog loader symbol existence + call-site enumeration in D:\ad-hoc\kei\design_agent\src\. NOT verified: concurrent-mtime equivalence (Stage 1 deferred); downstream test coverage breadth (Stage 4 pytest will surface regressions).
commit_sha: pending (Stage 3 unit commits will reference back).
FINAL_CONSENSUS: pending Codex review
[Claude #2] Stage 2 simulation-plan — IMP-27 K5 (catalog 로드 + _get_block_by_id 중복 cleanup)
=== ISSUE-BODY AXIS COVERAGE ===
| # | Axis | Unit |
|---|------|------|
| A1 | `block_reference._load_catalog` (list[dict]) | u1+u2 |
| A2 | `block_selector.load_catalog` (root dict) | u1+u3 |
| A3 | `renderer._load_catalog_map[_with_variants]` (derived) | u1+u4 |
| A4 | `block_reference._get_block_by_id` (no-arg) | u1+u2 |
| A5 | `block_selector._get_block_by_id` (catalog-injected) | u1+u3 |
| G1 | catalog.yaml 무변경 / 로드 path 만 통합 | u1~u4 |
| G2 | Phase R'/Z 무영향 / call-site contract 보존 | u1~u4 |
Coverage: every §scope axis maps to ≥1 unit. PASS.
=== KEY DECISIONS (Stage 1 Q1~Q3 resolved) ===
- Q1 → NEW `src/catalog.py` (block_reference single-responsibility 유지, asymmetric ownership 회피).
- Q2 → renderer projection cache 는 renderer-local 유지, raw catalog read 만 shared 위임, projection 무효화는 shared mtime 으로 keying.
- Q3 → canonical `get_block_by_id(block_id, catalog=None)` (catalog-injected 일반형; None 시 자동 load). 두 기존 form → 1-line wrapper.
=== IMPLEMENTATION_UNITS ===
- id: u1
summary: Create src/catalog.py shared loader (load_root_catalog, load_blocks, get_block_by_id, get_catalog_mtime) with single mtime cache primitive.
files: [src/catalog.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 45
- id: u2
summary: Delegate block_reference._load_catalog and _get_block_by_id to src.catalog; remove module-level _catalog_cache.
files: [src/block_reference.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 18
- id: u3
summary: Delegate block_selector.load_catalog and _get_block_by_id to src.catalog; remove module-level _catalog_cache/_catalog_mtime.
files: [src/block_selector.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 22
- id: u4
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume src.catalog.load_root_catalog; keep renderer-local projection cache keyed off shared mtime.
files: [src/renderer.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 35
=== PER-UNIT RATIONALE ===
u1 — Sole owner of file-read + mtime cache. API subsumes both existing contracts (list-of-blocks + root-dict + caller-injected get_block_by_id). Test = 5 cases: 4 functions + mtime invalidation.
u2 — block_reference loader/helper → 1-line wrappers. Internal sites L126/260/324/462/657/666/713 byte-identical. External callers fit_verifier 4 / space_allocator 5 / pipeline 1 untouched.
u3 — block_selector loader/helper → 1-line wrappers. `[Q-2] N개 블록` info log moves into shared cache-miss path (logged once per file change — observably equivalent).
u4 — Projections rebuild on shared-mtime change; cache renderer-side. Sites L77/L126/L135 byte-identical. CATALOG_PATH + projection cache names retained for minimal diff.
=== OUT-OF-SCOPE ===
- block_assembler.py (L384/L916/L997), block_search.py (L27), design_director.py (L417/L433/L872/L884), scripts/build_block_index.py L37, tests/test_catalog_invariant.py internal loader — NOT §scope (issue = 3 module only). Follow-up candidate "IMP-27b" deferred; not opened by this PR.
- No change to templates/catalog.yaml, mtime caching policy beyond consolidation, Phase Z runtime, call-site signatures, return shapes.
=== ROLLBACK ===
1 unit = 1 atomic commit. Rollback = `git revert <sha>` reverse u4→u3→u2→u1. Call-site contracts preserved → behavior fully restored on revert.
=== EVIDENCE ===
- Stage 1 binding contract inherited (key_files, callers, scope_lock, guardrails).
- Re-verified this turn via grep on `src/`: internal — block_reference._load_catalog (5), block_reference._get_block_by_id (2), block_selector.load_catalog (2), block_selector._get_block_by_id (2), renderer._load_catalog_map (3), renderer._load_catalog_map_with_variants (1). External — fit_verifier 4 / space_allocator 5 / pipeline 1 / scripts/test_phase_q.py 1.
- tests/ grep: test_catalog_invariant.py has its own internal `_load_catalog` (not src import) → no test impact. scripts/build_block_index.py has its own `load_catalog` → out of scope.
- Out-of-scope module enumeration (block_assembler / block_search / design_director) confirmed via `grep catalog.yaml src/`.
- Scope-qualified axis: catalog loader symbol existence + call-site enumeration in `D:\ad-hoc\kei\design_agent\src\`. NOT verified: concurrent-mtime equivalence (Stage 1 deferred); downstream test coverage breadth (Stage 4 pytest will surface regressions).
- commit_sha: pending (Stage 3 unit commits will reference back).
FINAL_CONSENSUS: pending Codex review
id: u1
summary: Create src/catalog.py as the sole file-read + mtime-cache owner with load_root_catalog, load_blocks, get_block_by_id, and get_catalog_mtime.
files: [src/catalog.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 45
id: u2
summary: Delegate block_reference._load_catalog and block_reference._get_block_by_id to src.catalog while preserving list[dict] and no-catalog-argument contracts.
files: [src/block_reference.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 18
id: u3
summary: Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog while preserving root-dict and catalog-injected contracts.
files: [src/block_selector.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 22
id: u4
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches.
files: [src/renderer.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 35
=== PER-UNIT RATIONALE ===
u1: Covers the issue's duplicated catalog-load axis by centralizing the only shared primitive: catalog.yaml read + mtime cache. Keeps schema and path unchanged. Unit is 1 file and under 50 estimated lines.
u2: Covers block_reference list-return loader and no-argument _get_block_by_id without touching fit_verifier, space_allocator, pipeline, or internal callers. Unit is 1 file and under 50 estimated lines.
u3: Covers block_selector root-dict loader and catalog-injected _get_block_by_id while preserving existing public signatures. Unit is 1 file and under 50 estimated lines.
u4: Covers renderer projection loaders as derived maps, not new root readers, preserving id->template and id--variant->template semantics. Unit is 1 file and under 50 estimated lines.
=== OUT-OF-SCOPE ===
No catalog.yaml content or schema change.
No return-shape, caller-signature, runtime Phase Z, frame_selection, light_edit, or restructure change.
No expansion to block_matcher_tfidf.py, design_director.py, block_assembler.py, block_search.py, or scripts/build_block_index.
=== ROLLBACK STRATEGY ===
Revert the Stage 3 commit that introduces src/catalog.py and the three delegating edits; no data migration or catalog content rollback is required.
Test-Path src/catalog.py; Test-Path tests/test_catalog_shared_loader.py; list tests directory
pytest -q tests
Files checked:
D:\ad-hoc\kei\design_agent\src\block_reference.py
D:\ad-hoc\kei\design_agent\src\block_selector.py
D:\ad-hoc\kei\design_agent\src\renderer.py
D:\ad-hoc\kei\design_agent\src\fit_verifier.py
D:\ad-hoc\kei\design_agent\src\space_allocator.py
D:\ad-hoc\kei\design_agent\src\pipeline.py
D:\ad-hoc\kei\design_agent\tests
Test results:
pytest -q tests: 342 passed in 40.56s
Verified facts:
Issue axes are all mapped: block_reference loader/helper, block_selector loader/helper, and renderer map/variant-map loaders.
Existing call-site scan matches the Stage 1 blast radius, except pipeline currently appears at src/pipeline.py:280 rather than the older report line 228.
Each planned implementation unit is <= 1 code file and <= 50 estimated lines, so all units satisfy the <=3 files / <=50 lines limit.
Planned tests include new tests/test_catalog_shared_loader.py; that file does not currently exist, so Stage 3 must add it.
Current baseline test suite is green before implementation.
FINAL_CONSENSUS: YES
[Codex #2] Stage 2 simulation-plan review IMP-27 K5
=== IMPLEMENTATION_UNITS ===
units:
- id: u1
summary: Create src/catalog.py as the sole file-read + mtime-cache owner with load_root_catalog, load_blocks, get_block_by_id, and get_catalog_mtime.
files: [src/catalog.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 45
- id: u2
summary: Delegate block_reference._load_catalog and block_reference._get_block_by_id to src.catalog while preserving list[dict] and no-catalog-argument contracts.
files: [src/block_reference.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 18
- id: u3
summary: Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog while preserving root-dict and catalog-injected contracts.
files: [src/block_selector.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 22
- id: u4
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches.
files: [src/renderer.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 35
=== PER-UNIT RATIONALE ===
u1: Covers the issue's duplicated catalog-load axis by centralizing the only shared primitive: catalog.yaml read + mtime cache. Keeps schema and path unchanged. Unit is 1 file and under 50 estimated lines.
u2: Covers block_reference list-return loader and no-argument _get_block_by_id without touching fit_verifier, space_allocator, pipeline, or internal callers. Unit is 1 file and under 50 estimated lines.
u3: Covers block_selector root-dict loader and catalog-injected _get_block_by_id while preserving existing public signatures. Unit is 1 file and under 50 estimated lines.
u4: Covers renderer projection loaders as derived maps, not new root readers, preserving id->template and id--variant->template semantics. Unit is 1 file and under 50 estimated lines.
=== OUT-OF-SCOPE ===
- No catalog.yaml content or schema change.
- No return-shape, caller-signature, runtime Phase Z, frame_selection, light_edit, or restructure change.
- No expansion to block_matcher_tfidf.py, design_director.py, block_assembler.py, block_search.py, or scripts/build_block_index.
=== ROLLBACK STRATEGY ===
Revert the Stage 3 commit that introduces src/catalog.py and the three delegating edits; no data migration or catalog content rollback is required.
=== EVIDENCE ===
Commands run:
- rg -n "def (_load_catalog|load_catalog|_load_catalog_map|_load_catalog_map_with_variants|_get_block_by_id)|_load_catalog\(|load_catalog\(|_load_catalog_map\(|_load_catalog_map_with_variants\(|_get_block_by_id\(" src
- Get-Content src/block_reference.py around loader/helper definitions
- Get-Content src/block_selector.py around loader/helper definitions
- Get-Content src/renderer.py around projection loader definitions
- rg -n "catalog|block_reference|block_selector|renderer|_load_catalog|load_catalog|get_block_by_id" tests
- Test-Path src/catalog.py; Test-Path tests/test_catalog_shared_loader.py; list tests directory
- pytest -q tests
Files checked:
- D:\ad-hoc\kei\design_agent\src\block_reference.py
- D:\ad-hoc\kei\design_agent\src\block_selector.py
- D:\ad-hoc\kei\design_agent\src\renderer.py
- D:\ad-hoc\kei\design_agent\src\fit_verifier.py
- D:\ad-hoc\kei\design_agent\src\space_allocator.py
- D:\ad-hoc\kei\design_agent\src\pipeline.py
- D:\ad-hoc\kei\design_agent\tests
Test results:
- pytest -q tests: 342 passed in 40.56s
Verified facts:
- Issue axes are all mapped: block_reference loader/helper, block_selector loader/helper, and renderer map/variant-map loaders.
- Existing call-site scan matches the Stage 1 blast radius, except pipeline currently appears at src/pipeline.py:280 rather than the older report line 228.
- Each planned implementation unit is <= 1 code file and <= 50 estimated lines, so all units satisfy the <=3 files / <=50 lines limit.
- Planned tests include new tests/test_catalog_shared_loader.py; that file does not currently exist, so Stage 3 must add it.
- Current baseline test suite is green before implementation.
FINAL_CONSENSUS: YES
u1 introduces a new module + new tests only. No edits to block_reference.py, block_selector.py, renderer.py, fit_verifier.py, space_allocator.py, pipeline.py (those are u2/u3/u4).
catalog.yaml schema/path frozen.
No runtime AI path touched. Phase Z 22-step pipeline untouched.
git status --short for this unit: 2 untracked files (src/catalog.py, tests/test_catalog_shared_loader.py). Existing tracked files unchanged.
■ remaining_units
u2 — Delegate block_reference._load_catalog and block_reference._get_block_by_id to src.catalog, preserving list[dict] and no-catalog-argument contracts.
u3 — Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog, preserving root-dict and catalog-injected contracts.
u4 — Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches keyed off get_catalog_mtime().
■ follow_up_issue_candidates
F-1 (informational, not scope creep): templates/catalog.yaml was deleted in commit cc2f434 ("cleanup: legacy templates/blocks") on the legacy-cleanup axis. The three loaders enumerated in Stage 1 therefore operate on a permanently-absent file in current main. Two of them (block_selector.load_catalog, renderer._load_catalog_map) already handle this gracefully; block_reference._load_catalog raises FileNotFoundError. After u2, that wrapper will inherit the graceful contract — this is the strict cleanup intent of IMP-27 and stays within "behavior-preserving" since the Phase Z runtime never reaches the raising path (per cc2f434 commit message: "Phase Z runtime / V4 catalog 영향 0"). No follow-up issue required; calling this out for Codex Stage 4 cross-check.
F-2 (out-of-scope, not actioned): src/catalog_blocks.py is a separate loader for templates/catalog/blocks.yaml (Phase Z block library), unrelated to the legacy templates/catalog.yaml chain. K5 axis does not cover it. No action.
Stage 1/2 scope-lock honored: no runtime path change; no schema change; no Phase Z 22-step edit; no AI in load path.
Commit / push: NONE (per Stage 3 instructions — comment is the deliverable).
[Claude #3] Stage 3 code-edit Round #1 — IMP-27 K5
Executing unit: u1
■ unit_executed
- id: u1
- summary: Create `src/catalog.py` as the sole file-read + mtime-cache owner with `load_root_catalog`, `load_blocks`, `get_block_by_id`, `get_catalog_mtime`.
■ files_changed
- NEW `D:\ad-hoc\kei\design_agent\src\catalog.py` (76 lines)
- NEW `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py` (171 lines, 10 tests)
■ diff_summary
- `src/catalog.py` (new module):
- Module constants: `CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml"`, `_catalog_cache: dict | None = None`, `_catalog_mtime: float = 0.0`.
- `load_root_catalog() -> dict`: file-read + mtime cache. Missing file → warns and returns `{"blocks": []}` (matches the pre-IMP-27 behavior of `block_selector.load_catalog` and `renderer._load_catalog_map`).
- `load_blocks() -> list[dict]`: returns `load_root_catalog().get("blocks", [])` — the contract that `block_reference._load_catalog` will delegate to in u2.
- `get_block_by_id(block_id, catalog=None) -> dict | None`: canonical form per Q3. `catalog=None` auto-loads (block_reference form); caller-supplied dict honored (block_selector form).
- `get_catalog_mtime() -> float`: exposes cached mtime so the renderer projection caches in u4 can key off the shared invalidation signal.
- `tests/test_catalog_shared_loader.py` (new test suite, 10 tests):
- Uses a `tmp_path` fixture + `monkeypatch` of `CATALOG_PATH` to avoid coupling tests to the deleted `templates/catalog.yaml` (see follow-up below).
- Covers: root-dict shape, list[dict] projection, both `get_block_by_id` signatures, unknown-id → None, single-file-read invariant on cold cache, mtime invalidation triggers reload, `get_catalog_mtime` semantics, missing-file → `{"blocks": []}`.
- `pytest tests/test_catalog_shared_loader.py -q` → 10 passed.
■ scope-lock verification
- u1 introduces a new module + new tests only. No edits to `block_reference.py`, `block_selector.py`, `renderer.py`, `fit_verifier.py`, `space_allocator.py`, `pipeline.py` (those are u2/u3/u4).
- catalog.yaml schema/path frozen.
- No runtime AI path touched. Phase Z 22-step pipeline untouched.
- `git status --short` for this unit: 2 untracked files (`src/catalog.py`, `tests/test_catalog_shared_loader.py`). Existing tracked files unchanged.
■ remaining_units
- u2 — Delegate `block_reference._load_catalog` and `block_reference._get_block_by_id` to `src.catalog`, preserving `list[dict]` and no-catalog-argument contracts.
- u3 — Delegate `block_selector.load_catalog` and `block_selector._get_block_by_id` to `src.catalog`, preserving root-dict and catalog-injected contracts.
- u4 — Refactor `renderer._load_catalog_map` and `_load_catalog_map_with_variants` to consume the shared root catalog while keeping renderer-local projection caches keyed off `get_catalog_mtime()`.
■ follow_up_issue_candidates
- **F-1 (informational, not scope creep)**: `templates/catalog.yaml` was deleted in commit `cc2f434` ("cleanup: legacy templates/blocks") on the legacy-cleanup axis. The three loaders enumerated in Stage 1 therefore operate on a permanently-absent file in current main. Two of them (`block_selector.load_catalog`, `renderer._load_catalog_map`) already handle this gracefully; `block_reference._load_catalog` raises `FileNotFoundError`. After u2, that wrapper will inherit the graceful contract — this is the strict cleanup intent of IMP-27 and stays within "behavior-preserving" since the Phase Z runtime never reaches the raising path (per cc2f434 commit message: "Phase Z runtime / V4 catalog 영향 0"). No follow-up issue required; calling this out for Codex Stage 4 cross-check.
- **F-2 (out-of-scope, not actioned)**: `src/catalog_blocks.py` is a *separate* loader for `templates/catalog/blocks.yaml` (Phase Z block library), unrelated to the legacy `templates/catalog.yaml` chain. K5 axis does not cover it. No action.
=== EVIDENCE ===
- Files created (paths verified above):
- `D:\ad-hoc\kei\design_agent\src\catalog.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- Tests run: `python -m pytest tests/test_catalog_shared_loader.py -q` → `10 passed in 0.06s`
- Source files not touched by u1 (confirmed via `git status --short src/ tests/test_catalog_shared_loader.py` → only the two new `??` entries):
- `src/block_reference.py`, `src/block_selector.py`, `src/renderer.py`, `src/fit_verifier.py`, `src/space_allocator.py`, `src/pipeline.py`
- Contract coverage:
- `load_root_catalog` → `dict` with `"blocks"` key (test_load_root_catalog_returns_root_dict)
- `load_blocks` → `list[dict]` (test_load_blocks_returns_list_of_block_dicts)
- `get_block_by_id(id)` no-arg form (test_get_block_by_id_without_catalog_arg)
- `get_block_by_id(id, catalog)` injected form (test_get_block_by_id_with_catalog_arg_preserves_block_selector_contract)
- mtime cache → 1 file-read across 4 calls (test_mtime_cache_does_single_file_read)
- mtime invalidation (test_mtime_change_triggers_reload)
- missing file graceful (test_load_root_catalog_missing_file_returns_empty_blocks)
- Stage 1/2 scope-lock honored: no runtime path change; no schema change; no Phase Z 22-step edit; no AI in load path.
- Commit / push: NONE (per Stage 3 instructions — comment is the deliverable).
Verdict: u1 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
u1 created the shared catalog reader module at D:\ad-hoc\kei\design_agent\src\catalog.py with load_root_catalog, load_blocks, get_block_by_id, and get_catalog_mtime.
load_root_catalog() owns the catalog file-read plus mtime cache and uses D:\ad-hoc\kei\design_agent\templates\catalog.yaml as the unified path.
load_blocks() preserves the list[dict] projection needed by the future block_reference._load_catalog wrapper.
get_block_by_id(block_id, catalog=None) supports both no-catalog auto-load and catalog-injected lookup forms.
get_catalog_mtime() exposes the cached mtime for the future renderer projection cache refactor.
The new focused tests cover root dict shape, blocks list projection, both lookup forms, unknown-id None, single yaml.safe_load on warm cache, mtime invalidation, cached mtime semantics, and missing-file fallback.
Scope qualification:
No tracked file diff is present from this unit.
src\block_reference.py, src\block_selector.py, and src\renderer.py still contain their old loaders; that is expected because u2, u3, and u4 remain.
The repository has many unrelated untracked files, so the prior "2 untracked files" statement is only valid as a unit-local scope claim, not as a repo-wide git status --short claim. This does not invalidate u1.
D:\ad-hoc\kei\design_agent\src\renderer.py
Test results:
pytest tests/test_catalog_shared_loader.py -q => 10 passed in 0.06s
Verified facts:
src/catalog.py and tests/test_catalog_shared_loader.py are not tracked yet, matching new-file u1 scope.
git diff --name-only returned no tracked modified files.
templates/catalog.yaml does not currently exist, and the new tests verify the missing-file empty catalog fallback.
Existing loaders in block_reference, block_selector, and renderer are unchanged and remain for u2-u4 delegation.
Remaining units: [u2, u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Codex #3] Stage 3 code-edit review IMP-27 K5
Verdict: u1 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
- u1 created the shared catalog reader module at `D:\ad-hoc\kei\design_agent\src\catalog.py` with `load_root_catalog`, `load_blocks`, `get_block_by_id`, and `get_catalog_mtime`.
- `load_root_catalog()` owns the catalog file-read plus mtime cache and uses `D:\ad-hoc\kei\design_agent\templates\catalog.yaml` as the unified path.
- `load_blocks()` preserves the `list[dict]` projection needed by the future `block_reference._load_catalog` wrapper.
- `get_block_by_id(block_id, catalog=None)` supports both no-catalog auto-load and catalog-injected lookup forms.
- `get_catalog_mtime()` exposes the cached mtime for the future renderer projection cache refactor.
- The new focused tests cover root dict shape, blocks list projection, both lookup forms, unknown-id `None`, single `yaml.safe_load` on warm cache, mtime invalidation, cached mtime semantics, and missing-file fallback.
Scope qualification:
- No tracked file diff is present from this unit.
- `src\block_reference.py`, `src\block_selector.py`, and `src\renderer.py` still contain their old loaders; that is expected because u2, u3, and u4 remain.
- The repository has many unrelated untracked files, so the prior "2 untracked files" statement is only valid as a unit-local scope claim, not as a repo-wide `git status --short` claim. This does not invalidate u1.
=== EVIDENCE ===
Commands run:
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\catalog.py'`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'`
- `rg -n "def (_load_catalog|load_catalog|_load_catalog_map|_load_catalog_map_with_variants|_get_block_by_id)|_catalog_cache|_catalog_mtime|from src\.catalog|import catalog" src tests`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_reference.py' -TotalCount 140`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_selector.py' -TotalCount 100`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\renderer.py' -TotalCount 110`
- `git ls-files --error-unmatch src/catalog.py tests/test_catalog_shared_loader.py`
- `pytest tests/test_catalog_shared_loader.py -q`
- `git diff --name-only`
- `Test-Path -LiteralPath 'D:\ad-hoc\kei\design_agent\templates\catalog.yaml'`
- `rg -n '^(CATALOG_PATH|def (load_root_catalog|load_blocks|get_block_by_id|get_catalog_mtime))|yaml\.safe_load|return \{"blocks": \[\]\}' src/catalog.py tests/test_catalog_shared_loader.py`
Files checked:
- `D:\ad-hoc\kei\design_agent\src\catalog.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- `D:\ad-hoc\kei\design_agent\src\block_reference.py`
- `D:\ad-hoc\kei\design_agent\src\block_selector.py`
- `D:\ad-hoc\kei\design_agent\src\renderer.py`
Test results:
- `pytest tests/test_catalog_shared_loader.py -q` => `10 passed in 0.06s`
Verified facts:
- `src/catalog.py` and `tests/test_catalog_shared_loader.py` are not tracked yet, matching new-file u1 scope.
- `git diff --name-only` returned no tracked modified files.
- `templates/catalog.yaml` does not currently exist, and the new tests verify the missing-file empty catalog fallback.
- Existing loaders in `block_reference`, `block_selector`, and `renderer` are unchanged and remain for u2-u4 delegation.
Remaining units: [u2, u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
summary: Delegate block_reference._load_catalog and block_reference._get_block_by_id to src.catalog, preserving list[dict] and no-catalog-argument contracts. Drop the module-level _catalog_cache dict and the dead yaml import.
■ files_changed
M D:\ad-hoc\kei\design_agent\src\block_reference.py (+7 / -20, 1 file, 27 lines net delta)
M D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py (+57, 4 new tests appended)
■ diff_summary
src/block_reference.py:
Header import block:
REMOVED import yaml (no remaining yaml.* call site in this module).
ADDED from src import catalog as _catalog_mod (single delegation entry point).
TEMPLATES_DIR, Path, Any retained (still used by Jinja env L38, raw template read L490, and ~10 type annotations).
Catalog loading section (formerly L104L129, now L104L116):
_load_catalog() -> list[dict] shrunk to 1-line wrapper: return _catalog_mod.load_blocks(). Preserves the exact list[dict] return contract that fit_verifier / space_allocator / pipeline import as _load_catalog (8 lazy-import call sites total — see scope-lock evidence below).
_get_block_by_id(block_id) -> dict | None shrunk to 1-line wrapper: return _catalog_mod.get_block_by_id(block_id). Preserves the no-catalog-argument signature consumed at block_reference.py:324 and block_reference.py:462.
Section header comment updated: 카탈로그 로딩 (mtime 캐싱) → 카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임) so future readers locate the delegation, not a now-empty cache layer.
tests/test_catalog_shared_loader.py (4 new tests appended after the u1 block):
test_block_reference_load_catalog_returns_list_of_blocks — verifies the list[dict] contract is preserved through the wrapper (matches fit_verifier / space_allocator / pipeline expectation).
test_block_reference_get_block_by_id_no_arg_signature — verifies the no-catalog-arg form still resolves an id and returns None for unknown ids.
test_block_reference_shares_cache_with_shared_loader — uses monkeypatch on catalog_mod.yaml.safe_load to count file reads; asserts that block_reference._load_catalog() + block_reference._get_block_by_id(...) + catalog.load_root_catalog() together produce exactly 1 file read. This is the regression guard for "no private _catalog_cache in block_reference".
test_block_reference_has_no_private_catalog_cache — explicit hasattr guard against _catalog_cache re-introduction.
■ verification
python -m pytest tests/test_catalog_shared_loader.py -q → 14 passed in 0.09s (10 u1 tests + 4 u2 delegation tests).
Lazy-import smoke (mirrors fit_verifier / space_allocator / pipeline pattern): from src.block_reference import _load_catalog; _load_catalog() → returns list (contract preserved). Note: templates/catalog.yaml is currently absent (deleted in cc2f434), so the call returns [] via shared loader's missing-file fallback rather than the old FileNotFoundError — see scope-qualified note below.
■ scope-lock verification (Stage 1 contract)
list[dict] contract on _load_catalog: preserved (test_block_reference_load_catalog_returns_list_of_blocks).
no-catalog-argument signature on _get_block_by_id: preserved (test_block_reference_get_block_by_id_no_arg_signature).
catalog.yaml schema/path: unchanged (zero edits to templates/catalog.yaml).
Internal call sites L260/L657/L666/L713 still call _load_catalog() → resolve to same shared loader; no signature change.
Internal call sites L324/L462 still call _get_block_by_id(block_id) → no signature change.
External lazy imports (fit_verifier.py:131,840,985,1004, space_allocator.py:34,390,786, pipeline.py:228) all do from src.block_reference import _load_catalog; symbol still exists with identical signature → byte-identical at the call site.
■ scope-qualified notes (not scope creep, flagged for transparency per RULE 5 factual-verification)
missing-file behavior consolidation: pre-IMP-27 block_reference._load_catalog raised FileNotFoundError on missing templates/catalog.yaml (no .exists() guard at the old L113, surfaced by Stage 1 problem-review). Post-u2, the shared loader returns {"blocks": []} and the wrapper returns []. This unifies behavior with block_selector.load_catalog and renderer._load_catalog_map which already returned empty on missing file. Stage 1 explicitly framed this as part of "file-read + mtime-cache layer 3 모듈 중복" consolidation; return contract (list[dict]) is unchanged. Since templates/catalog.yaml is currently deleted (cc2f434), no active runtime path exercises the missing-file branch — all 8 external lazy importers are themselves dead-import surfaces already flagged in Stage 1.
yaml import removed from block_reference: confirmed unused after delegation via grep -n "yaml" src/block_reference.py → only "catalog.yaml" string in docstring/comments remain. No other module imports yaml from block_reference (only the loader used it).
■ remaining_units
u3 — Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog, preserving root-dict and catalog-injected contracts; drop module-level _catalog_cache / _catalog_mtime.
u4 — Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches keyed off get_catalog_mtime().
■ follow_up_issue_candidates
(informational, NOT scope creep) F-1 carry-over from u1: templates/catalog.yaml itself is absent. All eight external lazy importers (fit_verifier 4, space_allocator 3, pipeline 1) now resolve to a list returning []. Whether to (a) delete those dead imports, (b) repoint to templates/catalog/blocks.yaml, or (c) keep the no-op guard is an axis already noted in Stage 1 unresolved-questions discussion. Out of u2 scope; flagged for follow-up issue if the project wants to remove the legacy callers entirely.
unit_executed: u2
files_changed: src/block_reference.py, tests/test_catalog_shared_loader.py
remaining_units: [u3, u4]
follow_up_issue_candidates: [F-1 (carry-over from u1 — legacy block_reference._load_catalog lazy importers in fit_verifier / space_allocator / pipeline now resolve to empty list[dict])]
[Claude #4] Stage 3 code-edit Round #2 — IMP-27 K5
Executing unit: u2
■ unit_executed
- id: u2
- summary: Delegate `block_reference._load_catalog` and `block_reference._get_block_by_id` to `src.catalog`, preserving `list[dict]` and no-catalog-argument contracts. Drop the module-level `_catalog_cache` dict and the dead `yaml` import.
■ files_changed
- M `D:\ad-hoc\kei\design_agent\src\block_reference.py` (+7 / -20, 1 file, 27 lines net delta)
- M `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py` (+57, 4 new tests appended)
■ diff_summary
- `src/block_reference.py`:
- Header import block:
- REMOVED `import yaml` (no remaining yaml.* call site in this module).
- ADDED `from src import catalog as _catalog_mod` (single delegation entry point).
- `TEMPLATES_DIR`, `Path`, `Any` retained (still used by Jinja env L38, raw template read L490, and ~10 type annotations).
- Catalog loading section (formerly L104~L129, now L104~L116):
- REMOVED module-level `_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}`.
- `_load_catalog() -> list[dict]` shrunk to 1-line wrapper: `return _catalog_mod.load_blocks()`. Preserves the exact `list[dict]` return contract that `fit_verifier` / `space_allocator` / `pipeline` import as `_load_catalog` (8 lazy-import call sites total — see scope-lock evidence below).
- `_get_block_by_id(block_id) -> dict | None` shrunk to 1-line wrapper: `return _catalog_mod.get_block_by_id(block_id)`. Preserves the no-catalog-argument signature consumed at `block_reference.py:324` and `block_reference.py:462`.
- Section header comment updated: `카탈로그 로딩 (mtime 캐싱)` → `카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임)` so future readers locate the delegation, not a now-empty cache layer.
- `tests/test_catalog_shared_loader.py` (4 new tests appended after the u1 block):
- `test_block_reference_load_catalog_returns_list_of_blocks` — verifies the `list[dict]` contract is preserved through the wrapper (matches fit_verifier / space_allocator / pipeline expectation).
- `test_block_reference_get_block_by_id_no_arg_signature` — verifies the no-catalog-arg form still resolves an id and returns `None` for unknown ids.
- `test_block_reference_shares_cache_with_shared_loader` — uses `monkeypatch` on `catalog_mod.yaml.safe_load` to count file reads; asserts that `block_reference._load_catalog()` + `block_reference._get_block_by_id(...)` + `catalog.load_root_catalog()` together produce exactly 1 file read. This is the regression guard for "no private _catalog_cache in block_reference".
- `test_block_reference_has_no_private_catalog_cache` — explicit `hasattr` guard against `_catalog_cache` re-introduction.
■ verification
- `python -m pytest tests/test_catalog_shared_loader.py -q` → **14 passed in 0.09s** (10 u1 tests + 4 u2 delegation tests).
- `python -c "from src.block_reference import _load_catalog, _get_block_by_id"` → resolves; both callable.
- Lazy-import smoke (mirrors `fit_verifier` / `space_allocator` / `pipeline` pattern): `from src.block_reference import _load_catalog; _load_catalog()` → returns `list` (contract preserved). Note: `templates/catalog.yaml` is currently absent (deleted in cc2f434), so the call returns `[]` via shared loader's missing-file fallback rather than the old `FileNotFoundError` — see scope-qualified note below.
■ scope-lock verification (Stage 1 contract)
- list[dict] contract on `_load_catalog`: **preserved** (test_block_reference_load_catalog_returns_list_of_blocks).
- no-catalog-argument signature on `_get_block_by_id`: **preserved** (test_block_reference_get_block_by_id_no_arg_signature).
- catalog.yaml schema/path: **unchanged** (zero edits to `templates/catalog.yaml`).
- Phase Z 22-step runtime, frame_selection, light_edit/restructure: **untouched** (only `src/block_reference.py` header + loader section edited).
- Internal call sites L260/L657/L666/L713 still call `_load_catalog()` → resolve to same shared loader; no signature change.
- Internal call sites L324/L462 still call `_get_block_by_id(block_id)` → no signature change.
- External lazy imports (`fit_verifier.py:131,840,985,1004`, `space_allocator.py:34,390,786`, `pipeline.py:228`) all do `from src.block_reference import _load_catalog`; symbol still exists with identical signature → **byte-identical at the call site**.
■ scope-qualified notes (not scope creep, flagged for transparency per RULE 5 factual-verification)
- **missing-file behavior consolidation**: pre-IMP-27 `block_reference._load_catalog` raised `FileNotFoundError` on missing `templates/catalog.yaml` (no `.exists()` guard at the old L113, surfaced by Stage 1 problem-review). Post-u2, the shared loader returns `{"blocks": []}` and the wrapper returns `[]`. This unifies behavior with `block_selector.load_catalog` and `renderer._load_catalog_map` which already returned empty on missing file. Stage 1 explicitly framed this as part of "file-read + mtime-cache layer 3 모듈 중복" consolidation; **return contract** (list[dict]) is unchanged. Since `templates/catalog.yaml` is currently deleted (cc2f434), no active runtime path exercises the missing-file branch — all 8 external lazy importers are themselves dead-import surfaces already flagged in Stage 1.
- **`yaml` import removed from block_reference**: confirmed unused after delegation via `grep -n "yaml" src/block_reference.py` → only "catalog.yaml" string in docstring/comments remain. No other module imports `yaml` from `block_reference` (only the loader used it).
■ remaining_units
- u3 — Delegate `block_selector.load_catalog` and `block_selector._get_block_by_id` to `src.catalog`, preserving root-dict and catalog-injected contracts; drop module-level `_catalog_cache` / `_catalog_mtime`.
- u4 — Refactor `renderer._load_catalog_map` and `_load_catalog_map_with_variants` to consume the shared root catalog while keeping renderer-local projection caches keyed off `get_catalog_mtime()`.
■ follow_up_issue_candidates
- (informational, NOT scope creep) **F-1 carry-over from u1**: `templates/catalog.yaml` itself is absent. All eight external lazy importers (`fit_verifier` 4, `space_allocator` 3, `pipeline` 1) now resolve to a `list` returning `[]`. Whether to (a) delete those dead imports, (b) repoint to `templates/catalog/blocks.yaml`, or (c) keep the no-op guard is an axis already noted in Stage 1 unresolved-questions discussion. Out of u2 scope; flagged for follow-up issue if the project wants to remove the legacy callers entirely.
unit_executed: u2
files_changed: src/block_reference.py, tests/test_catalog_shared_loader.py
remaining_units: [u3, u4]
follow_up_issue_candidates: [F-1 (carry-over from u1 — legacy block_reference._load_catalog lazy importers in fit_verifier / space_allocator / pipeline now resolve to empty list[dict])]
Verdict: u2 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
u2 delegates src.block_reference._load_catalog() to src.catalog.load_blocks(), preserving the no-argument list[dict] contract used by existing callers.
u2 delegates src.block_reference._get_block_by_id(block_id) to src.catalog.get_block_by_id(block_id), preserving the no-catalog-argument lookup contract.
src.block_reference no longer imports yaml and no longer defines its own _catalog_cache.
The focused u2 tests were added to tests/test_catalog_shared_loader.py and pass together with the u1 shared-loader tests.
Scope qualification:
src/block_selector.py and src/renderer.py are still unchanged; that is expected because u3 and u4 remain.
src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py call sites remain untouched and still call _load_catalog() with no arguments.
No templates/catalog.yaml content was changed.
I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
src/block_reference.py imports from src import catalog as _catalog_mod.
src/block_reference.py has no import yaml, no yaml.* call, and no _catalog_cache definition.
src/block_reference.py defines _load_catalog() -> list[dict] as a wrapper around _catalog_mod.load_blocks().
src/block_reference.py defines _get_block_by_id(block_id: str) -> dict | None as a wrapper around _catalog_mod.get_block_by_id(block_id).
Signature smoke output is () -> 'list[dict]' and (block_id: 'str') -> 'dict | None'.
src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py still use no-argument _load_catalog() call sites.
Current scoped status is M src/block_reference.py, ?? src/catalog.py, and ?? tests/test_catalog_shared_loader.py; no scoped changes are present for src/block_selector.py, src/renderer.py, src/fit_verifier.py, src/space_allocator.py, src/pipeline.py, or templates/catalog.yaml.
u2 is complete; u3 and u4 remain.
Remaining units: [u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Codex #4] Stage 3 code-edit review IMP-27 K5
Verdict: u2 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
- u2 delegates `src.block_reference._load_catalog()` to `src.catalog.load_blocks()`, preserving the no-argument `list[dict]` contract used by existing callers.
- u2 delegates `src.block_reference._get_block_by_id(block_id)` to `src.catalog.get_block_by_id(block_id)`, preserving the no-catalog-argument lookup contract.
- `src.block_reference` no longer imports `yaml` and no longer defines its own `_catalog_cache`.
- The focused u2 tests were added to `tests/test_catalog_shared_loader.py` and pass together with the u1 shared-loader tests.
Scope qualification:
- `src/block_selector.py` and `src/renderer.py` are still unchanged; that is expected because u3 and u4 remain.
- `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` call sites remain untouched and still call `_load_catalog()` with no arguments.
- No `templates/catalog.yaml` content was changed.
- I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
=== EVIDENCE ===
Commands run:
- `git diff -- src/block_reference.py tests/test_catalog_shared_loader.py`
- `rg -n "import yaml|from src import catalog|_catalog_cache|def _load_catalog|def _get_block_by_id|yaml\\." src/block_reference.py tests/test_catalog_shared_loader.py`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_reference.py' -TotalCount 150`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'`
- `python -m pytest tests/test_catalog_shared_loader.py -q`
- `rg -n "_load_catalog\\(|_get_block_by_id\\(" src/block_reference.py src/fit_verifier.py src/space_allocator.py src/pipeline.py`
- `git status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yaml`
- `python -c "from src.block_reference import _load_catalog, _get_block_by_id; import inspect; print(inspect.signature(_load_catalog)); print(inspect.signature(_get_block_by_id))"`
- `Test-Path -LiteralPath 'D:\ad-hoc\kei\design_agent\.orchestrator\drafts\27_stage_code-edit_codex_r2.md'`
Files checked:
- `D:\ad-hoc\kei\design_agent\src\block_reference.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- `D:\ad-hoc\kei\design_agent\src\fit_verifier.py`
- `D:\ad-hoc\kei\design_agent\src\space_allocator.py`
- `D:\ad-hoc\kei\design_agent\src\pipeline.py`
Test results:
- `python -m pytest tests/test_catalog_shared_loader.py -q` => `14 passed in 0.08s`
Verified facts:
- `src/block_reference.py` imports `from src import catalog as _catalog_mod`.
- `src/block_reference.py` has no `import yaml`, no `yaml.*` call, and no `_catalog_cache` definition.
- `src/block_reference.py` defines `_load_catalog() -> list[dict]` as a wrapper around `_catalog_mod.load_blocks()`.
- `src/block_reference.py` defines `_get_block_by_id(block_id: str) -> dict | None` as a wrapper around `_catalog_mod.get_block_by_id(block_id)`.
- Signature smoke output is `() -> 'list[dict]'` and `(block_id: 'str') -> 'dict | None'`.
- `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` still use no-argument `_load_catalog()` call sites.
- Current scoped status is `M src/block_reference.py`, `?? src/catalog.py`, and `?? tests/test_catalog_shared_loader.py`; no scoped changes are present for `src/block_selector.py`, `src/renderer.py`, `src/fit_verifier.py`, `src/space_allocator.py`, `src/pipeline.py`, or `templates/catalog.yaml`.
- u2 is complete; u3 and u4 remain.
Remaining units: [u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
summary: Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog, preserving the root-dict and catalog-injected contracts. Drop module-level CATALOG_PATH, _catalog_cache, _catalog_mtime, and the now-unused yaml / pathlib.Path imports.
■ files_changed
M D:\ad-hoc\kei\design_agent\src\block_selector.py (+7 / -32 net, git diff --stat confirms)
M D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py (+71, 4 new tests appended after the u2 block)
■ diff_summary
src/block_selector.py:
Header import block:
REMOVED from pathlib import Path (only consumer was the deleted CATALOG_PATH).
REMOVED import yaml (only consumer was the deleted load_catalog body).
ADDED from src import catalog as _catalog_mod (single delegation entry point — mirrors the u2 wiring in block_reference.py:30).
from typing import Any retained (still used by topic: dict[str, Any] in select_block_candidates).
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER retained.
Catalog loading section (formerly L57L83, now L51L58):
load_catalog() -> dict shrunk to 1-line wrapper: return _catalog_mod.load_root_catalog(). Preserves the root-dict return contract consumed by select_block_candidates (L83 → L86 of the new file), select_fallback_candidates (post-edit L177 region), scripts/test_phase_q.py:26 (external import from src.block_selector import ... load_catalog), and the format_candidates_for_prompt caller chain.
_get_block_by_id(block_id: str, catalog: dict) -> dict | None shrunk to 1-line wrapper: return _catalog_mod.get_block_by_id(block_id, catalog). Preserves the catalog-injected signature (positional catalog required by the existing definition; the canonical src.catalog.get_block_by_id accepts a positional catalog so the wrapper is byte-equivalent at call sites).
Section header comment updated: catalog.yaml 로딩 (mtime 캐시) → catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임) so future readers locate the delegation, not a now-deleted local cache.
tests/test_catalog_shared_loader.py (4 new tests appended after the u2 block, lines 237~303):
test_block_selector_load_catalog_returns_root_dict — verifies the root-dict contract (isinstance(root, dict), "blocks" in root, isinstance(root["blocks"], list)) is preserved through the wrapper. This is the contract that select_block_candidates(catalog=None) and scripts/test_phase_q.py:26 rely on.
test_block_selector_get_block_by_id_catalog_injected_signature — verifies the catalog-injected form still resolves an id from a caller-supplied dict and returns None for unknown ids (asserts block_selector._get_block_by_id(block_id, catalog_dict) still accepts positional catalog).
test_block_selector_shares_cache_with_shared_loader — uses monkeypatch on catalog_mod.yaml.safe_load to count file reads; asserts that block_selector.load_catalog() + block_selector._get_block_by_id(...) + catalog.load_root_catalog() together produce exactly 1 file read. This is the regression guard for "no private _catalog_cache in block_selector".
test_block_selector_has_no_private_catalog_cache — explicit hasattr guards against re-introduction of _catalog_cache, _catalog_mtime, or CATALOG_PATH on the block_selector module.
■ scope-lock verification
u3 edits only src/block_selector.py + test file. No edits to src/renderer.py (u4) or any caller-side file (src/kei_client.py:454, scripts/test_phase_q.py:26).
git diff --name-only -- src/ tests/ lists only src/block_reference.py (u2 carry-over from Round #2; unchanged this round) and src/block_selector.py. tests/test_catalog_shared_loader.py and src/catalog.py remain untracked (u1 carry-over). No spurious file in scope.
catalog.yaml schema/path frozen — src.catalog.CATALOG_PATH is the single source of truth and is unchanged.
No runtime AI path touched. Phase Z 22-step pipeline untouched. AI isolation contract preserved (no AI call in load path).
Public surface verification: python -c "from src.block_selector import load_catalog, _get_block_by_id; import inspect; print(inspect.signature(load_catalog), inspect.signature(_get_block_by_id))" → () -> 'dict' and (block_id: 'str', catalog: 'dict') -> 'dict | None' — byte-identical to the pre-IMP-27 signatures.
Caller-side audit: rg -n "block_selector\\.(CATALOG_PATH|_catalog_cache|_catalog_mtime)|from src\\.block_selector import.*(CATALOG_PATH|_catalog_cache|_catalog_mtime)" --include="*.py" . → only the new u3 guard test lines (303). No other code in the repo depends on the removed symbols, so no caller-side update is required.
python -c "from src.block_selector import load_catalog, _get_block_by_id, select_block_candidates, format_candidates_for_prompt" → resolves; full public surface importable.
git diff --stat -- src/block_selector.py → 1 file changed, 7 insertions(+), 32 deletions(-). Net delta consistent with "delegate + drop local cache" — no scope creep.
git status --short -- src/block_selector.py tests/test_catalog_shared_loader.py src/catalog.py → M src/block_selector.py, ?? tests/test_catalog_shared_loader.py, ?? src/catalog.py. Consistent with cumulative state across u1+u2+u3.
■ remaining_units
u4 — Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches keyed off src.catalog.get_catalog_mtime().
■ follow_up_issue_candidates
None new this round. F-1 from Round #1 (templates/catalog.yaml was deleted in cc2f434 during a legacy block library cleanup; the loader contract still exists for any remaining Phase Q call sites but no production catalog file is present) remains informational — outside IMP-27 K5 scope; surface separately if any Phase Q runtime path actually needs a populated catalog.
scope-lock retained. Awaiting Codex u3 review before proceeding to u4.
[Claude #5] Stage 3 code-edit Round #3 — IMP-27 K5
Executing unit: u3
■ unit_executed
- id: u3
- summary: Delegate `block_selector.load_catalog` and `block_selector._get_block_by_id` to `src.catalog`, preserving the root-dict and catalog-injected contracts. Drop module-level `CATALOG_PATH`, `_catalog_cache`, `_catalog_mtime`, and the now-unused `yaml` / `pathlib.Path` imports.
■ files_changed
- M `D:\ad-hoc\kei\design_agent\src\block_selector.py` (+7 / -32 net, `git diff --stat` confirms)
- M `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py` (+71, 4 new tests appended after the u2 block)
■ diff_summary
- `src/block_selector.py`:
- Header import block:
- REMOVED `from pathlib import Path` (only consumer was the deleted `CATALOG_PATH`).
- REMOVED `import yaml` (only consumer was the deleted `load_catalog` body).
- ADDED `from src import catalog as _catalog_mod` (single delegation entry point — mirrors the u2 wiring in `block_reference.py:30`).
- `from typing import Any` retained (still used by `topic: dict[str, Any]` in `select_block_candidates`).
- `from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER` retained.
- Module-level state (formerly L22~L24):
- REMOVED `CATALOG_PATH = Path("templates/catalog.yaml")`.
- REMOVED `_catalog_cache: dict | None = None`.
- REMOVED `_catalog_mtime: float = 0.0`.
- Catalog loading section (formerly L57~L83, now L51~L58):
- `load_catalog() -> dict` shrunk to 1-line wrapper: `return _catalog_mod.load_root_catalog()`. Preserves the root-dict return contract consumed by `select_block_candidates` (L83 → L86 of the new file), `select_fallback_candidates` (post-edit L177 region), `scripts/test_phase_q.py:26` (external import `from src.block_selector import ... load_catalog`), and the `format_candidates_for_prompt` caller chain.
- `_get_block_by_id(block_id: str, catalog: dict) -> dict | None` shrunk to 1-line wrapper: `return _catalog_mod.get_block_by_id(block_id, catalog)`. Preserves the catalog-injected signature (positional `catalog` required by the existing definition; the canonical `src.catalog.get_block_by_id` accepts a positional `catalog` so the wrapper is byte-equivalent at call sites).
- Section header comment updated: `catalog.yaml 로딩 (mtime 캐시)` → `catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임)` so future readers locate the delegation, not a now-deleted local cache.
- `tests/test_catalog_shared_loader.py` (4 new tests appended after the u2 block, lines 237~303):
- `test_block_selector_load_catalog_returns_root_dict` — verifies the root-dict contract (`isinstance(root, dict)`, `"blocks" in root`, `isinstance(root["blocks"], list)`) is preserved through the wrapper. This is the contract that `select_block_candidates(catalog=None)` and `scripts/test_phase_q.py:26` rely on.
- `test_block_selector_get_block_by_id_catalog_injected_signature` — verifies the catalog-injected form still resolves an id from a caller-supplied dict and returns `None` for unknown ids (asserts `block_selector._get_block_by_id(block_id, catalog_dict)` still accepts positional catalog).
- `test_block_selector_shares_cache_with_shared_loader` — uses `monkeypatch` on `catalog_mod.yaml.safe_load` to count file reads; asserts that `block_selector.load_catalog()` + `block_selector._get_block_by_id(...)` + `catalog.load_root_catalog()` together produce exactly 1 file read. This is the regression guard for "no private `_catalog_cache` in `block_selector`".
- `test_block_selector_has_no_private_catalog_cache` — explicit `hasattr` guards against re-introduction of `_catalog_cache`, `_catalog_mtime`, or `CATALOG_PATH` on the `block_selector` module.
■ scope-lock verification
- u3 edits only `src/block_selector.py` + test file. No edits to `src/renderer.py` (u4) or any caller-side file (`src/kei_client.py:454`, `scripts/test_phase_q.py:26`).
- `git diff --name-only -- src/ tests/` lists only `src/block_reference.py` (u2 carry-over from Round #2; unchanged this round) and `src/block_selector.py`. `tests/test_catalog_shared_loader.py` and `src/catalog.py` remain untracked (u1 carry-over). No spurious file in scope.
- catalog.yaml schema/path frozen — `src.catalog.CATALOG_PATH` is the single source of truth and is unchanged.
- No runtime AI path touched. Phase Z 22-step pipeline untouched. AI isolation contract preserved (no AI call in load path).
- Public surface verification: `python -c "from src.block_selector import load_catalog, _get_block_by_id; import inspect; print(inspect.signature(load_catalog), inspect.signature(_get_block_by_id))"` → `() -> 'dict'` and `(block_id: 'str', catalog: 'dict') -> 'dict | None'` — byte-identical to the pre-IMP-27 signatures.
- Caller-side audit: `rg -n "block_selector\\.(CATALOG_PATH|_catalog_cache|_catalog_mtime)|from src\\.block_selector import.*(CATALOG_PATH|_catalog_cache|_catalog_mtime)" --include="*.py" .` → only the new u3 guard test lines (303). No other code in the repo depends on the removed symbols, so no caller-side update is required.
■ verification
- `python -m pytest tests/test_catalog_shared_loader.py -q` → **18 passed in 0.26s** (10 u1 tests + 4 u2 tests + 4 u3 tests).
- `python -c "from src.block_selector import load_catalog, _get_block_by_id, select_block_candidates, format_candidates_for_prompt"` → resolves; full public surface importable.
- `git diff --stat -- src/block_selector.py` → `1 file changed, 7 insertions(+), 32 deletions(-)`. Net delta consistent with "delegate + drop local cache" — no scope creep.
- `git status --short -- src/block_selector.py tests/test_catalog_shared_loader.py src/catalog.py` → `M src/block_selector.py`, `?? tests/test_catalog_shared_loader.py`, `?? src/catalog.py`. Consistent with cumulative state across u1+u2+u3.
■ remaining_units
- u4 — Refactor `renderer._load_catalog_map` and `_load_catalog_map_with_variants` to consume the shared root catalog while keeping renderer-local projection caches keyed off `src.catalog.get_catalog_mtime()`.
■ follow_up_issue_candidates
- None new this round. F-1 from Round #1 (`templates/catalog.yaml` was deleted in cc2f434 during a legacy block library cleanup; the loader contract still exists for any remaining Phase Q call sites but no production catalog file is present) remains informational — outside IMP-27 K5 scope; surface separately if any Phase Q runtime path actually needs a populated catalog.
scope-lock retained. Awaiting Codex u3 review before proceeding to u4.
Verdict: u3 is correct and scope-locked. Continue with the remaining implementation unit.
Verified unit:
u3 delegates src.block_selector.load_catalog() to src.catalog.load_root_catalog(), preserving the no-argument root-dict contract used internally and by scripts/test_phase_q.py.
u3 delegates src.block_selector._get_block_by_id(block_id, catalog) to src.catalog.get_block_by_id(block_id, catalog), preserving the catalog-injected signature.
src.block_selector no longer imports yaml or pathlib.Path, and no longer defines CATALOG_PATH, _catalog_cache, or _catalog_mtime.
The focused u3 tests were added to tests/test_catalog_shared_loader.py and pass together with the existing u1 and u2 tests.
Scope qualification:
src/renderer.py is still unchanged; that is expected because u4 remains.
src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py call sites remain untouched.
No templates/catalog.yaml content was changed.
I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
src/block_selector.py has no yaml, Path, CATALOG_PATH, _catalog_cache, or _catalog_mtime matches.
scripts/test_phase_q.py still imports and calls load_catalog() with no arguments.
src/renderer.py has no diff in this unit and remains for u4.
Remaining units: [u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Codex #5] Stage 3 code-edit review IMP-27 K5
Verdict: u3 is correct and scope-locked. Continue with the remaining implementation unit.
Verified unit:
- u3 delegates `src.block_selector.load_catalog()` to `src.catalog.load_root_catalog()`, preserving the no-argument root-dict contract used internally and by `scripts/test_phase_q.py`.
- u3 delegates `src.block_selector._get_block_by_id(block_id, catalog)` to `src.catalog.get_block_by_id(block_id, catalog)`, preserving the catalog-injected signature.
- `src.block_selector` no longer imports `yaml` or `pathlib.Path`, and no longer defines `CATALOG_PATH`, `_catalog_cache`, or `_catalog_mtime`.
- The focused u3 tests were added to `tests/test_catalog_shared_loader.py` and pass together with the existing u1 and u2 tests.
Scope qualification:
- `src/renderer.py` is still unchanged; that is expected because u4 remains.
- `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` call sites remain untouched.
- No `templates/catalog.yaml` content was changed.
- I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
=== EVIDENCE ===
Commands run:
- `git diff -- src/block_selector.py tests/test_catalog_shared_loader.py`
- `rg -n "from pathlib import Path|import yaml|from src import catalog|CATALOG_PATH|_catalog_cache|_catalog_mtime|def load_catalog|def _get_block_by_id|yaml\." src/block_selector.py tests/test_catalog_shared_loader.py`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_selector.py' -TotalCount 120`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'`
- `python -m pytest tests/test_catalog_shared_loader.py -q`
- `rg -n "load_catalog\(|_get_block_by_id\(" src/block_selector.py scripts tests src | sort`
- `python -c "from src.block_selector import load_catalog, _get_block_by_id; import inspect; print(inspect.signature(load_catalog)); print(inspect.signature(_get_block_by_id))"`
- `git status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yaml`
- `rg -n "from src\.block_selector import|import .*block_selector|select_block_candidates|select_fallback_candidates|load_catalog" scripts/test_phase_q.py src/block_selector.py`
- `rg -n "from pathlib import Path|import yaml|CATALOG_PATH|_catalog_cache|_catalog_mtime|yaml\." src/block_selector.py`
- `git diff -- src/renderer.py`
Files checked:
- `D:\ad-hoc\kei\design_agent\src\block_selector.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- `D:\ad-hoc\kei\design_agent\scripts\test_phase_q.py`
- `D:\ad-hoc\kei\design_agent\src\renderer.py`
Test results:
- `python -m pytest tests/test_catalog_shared_loader.py -q` => `18 passed in 0.08s`
Verified facts:
- `src/block_selector.py` imports `from src import catalog as _catalog_mod`.
- `load_catalog() -> dict` returns `_catalog_mod.load_root_catalog()`.
- `_get_block_by_id(block_id: str, catalog: dict) -> dict | None` returns `_catalog_mod.get_block_by_id(block_id, catalog)`.
- `src/block_selector.py` has no `yaml`, `Path`, `CATALOG_PATH`, `_catalog_cache`, or `_catalog_mtime` matches.
- `scripts/test_phase_q.py` still imports and calls `load_catalog()` with no arguments.
- `src/renderer.py` has no diff in this unit and remains for u4.
Remaining units: [u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog via src.catalog.load_blocks(), keep renderer-local projection caches, and key projection invalidation off src.catalog.get_catalog_mtime(). Drop renderer.CATALOG_PATH, the yaml import, and the legacy single _CATALOG_MTIME module global; replace with per-projection keys _CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME.
■ files_changed
M D:\ad-hoc\kei\design_agent\src\renderer.py (+39 / -49, net -10 lines per git diff --stat)
M D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py (+109, fixture extended + 5 new u4 tests appended; file remains untracked from u1)
■ diff_summary
src/renderer.py:
Header import block:
REMOVED import yaml (no remaining yaml.* call site in this module).
ADDED from src import catalog as _catalog_mod (single delegation entry point — mirrors u2 wiring at block_reference.py:30 and u3 wiring at block_selector.py).
from collections import OrderedDict, from pathlib import Path, from typing import Any, from jinja2 import Environment, FileSystemLoader, and logger retained (still used by TEMPLATES_DIR L21, STATIC_DIR L22, OrderedDict group grouping later in the file, Jinja env, and type hints).
Module-level state (formerly L23, L28L30, L65L66 → now L20~L26):
REMOVED _CATALOG_MTIME: float = 0.0 (single legacy key — replaced by per-projection keys so the two projections invalidate independently and correctly under get_catalog_mtime() keying).
ADDED _CATALOG_MAP_MTIME: float = 0.0 and _CATALOG_VARIANT_MAP_MTIME: float = 0.0 (renderer-local projection cache keys; values come from _catalog_mod.get_catalog_mtime()).
_CATALOG_VARIANT_MAP: dict[str, str] | None = None retained (projection cache); moved up next to its mtime key (formerly L65L66, now L25L26) so both projection caches sit together.
Section header comment updated: catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신) → id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache).
_load_catalog_map() -> dict[str, str] (formerly L32L62, now L29L55):
Body shrunk to projection-only: calls _catalog_mod.load_blocks() for raw data and _catalog_mod.get_catalog_mtime() for the cache key; projection map rebuild iterates the returned list[dict] and writes id → template.
File I/O (open(CATALOG_PATH, ...), yaml.safe_load, the missing-file warning, the exception swallowing) fully removed — those concerns now live in src.catalog.load_root_catalog.
Return contract unchanged: dict[str, str] keyed by block id. Logging line catalog.yaml 로드: {N}개 블록 매핑 preserved (callers / log monitors unaffected).
_load_catalog_map_with_variants() -> dict[str, str] (formerly L69L97, now L58L80):
Same projection-only pattern: consumes _catalog_mod.load_blocks() directly. Removed the redundant _load_catalog_map() pre-call (the variant cache now keys off get_catalog_mtime() independently, so the side-effectful "force the other cache to refresh" trick is no longer needed).
Removed the second open(CATALOG_PATH, ...) + yaml.safe_load block — single shared file read enforced by src.catalog.
Return contract unchanged: dict[str, str] keyed by "block_id--variant_id", excluding default variants. Verified by test_renderer_load_catalog_map_with_variants_returns_compound_key_dict below.
tests/test_catalog_shared_loader.py:
Fixture extended (lines 22~38): fixture-block-b now carries variants with a default and a compact entry, enabling the variant projection test to exercise the default-exclusion rule (preserves pre-IMP-27 behavior in renderer._load_catalog_map_with_variants).
_reset_renderer_projection_cache() helper added (lines 47~53) and called from the fixture_catalog_path fixture (line 65) so each test starts with both renderer-local projection caches cleared. Without this, the module-level state would leak across tests.
5 new u4 tests appended after the u3 block (lines 313~421):
test_renderer_load_catalog_map_returns_id_to_template_dict — verifies id → template projection contract (matches existing callers _resolve_template_path at L135 of the new renderer).
test_renderer_load_catalog_map_with_variants_returns_compound_key_dict — verifies "id--variant" → template projection, with explicit assertion that default variants are excluded.
test_renderer_shares_cache_with_shared_loader — monkeypatches src.catalog.yaml.safe_load to count file reads; asserts that renderer._load_catalog_map() + renderer._load_catalog_map_with_variants() + catalog.load_root_catalog() together produce exactly 1 file read. This is the regression guard for "renderer must not reopen the catalog file".
test_renderer_projection_invalidates_when_shared_mtime_changes — rewrites the fixture file, bumps mtime, resets src.catalog._catalog_cache, and asserts that renderer._load_catalog_map() returns the new contents. Locks the contract "renderer projection cache keyed off src.catalog.get_catalog_mtime()".
test_renderer_has_no_private_catalog_path_or_yaml — explicit hasattr guards against re-introduction of CATALOG_PATH, yaml, and the legacy _CATALOG_MTIME single-key.
git status --short for this unit: M src/renderer.py, ?? tests/test_catalog_shared_loader.py (untracked since u1). No other tracked files modified.
■ scope-lock verification
Only renderer's two map functions touched. No edits to src/catalog.py, src/block_reference.py, src/block_selector.py, src/fit_verifier.py, src/space_allocator.py, src/pipeline.py.
Renderer call sites of _load_catalog_map (L77 → now L126 region inside _resolve_template_path) and _load_catalog_map_with_variants (L126 region) preserved as no-arg calls returning dict[str, str] — verified by re-grep _load_catalog_map\( in src/renderer.py → unchanged signatures at the call site.
catalog.yaml schema/path frozen.
No runtime AI path touched. Phase Z 22-step pipeline untouched.
PZ-1 isolation preserved: renderer is Phase R' surface; the shared loader src.catalog introduced in u1 is loader-only and pulls no Phase R' rendering symbols.
■ remaining_units
(none — u1, u2, u3, u4 all executed across rounds 1~4)
■ follow_up_issue_candidates
F-1 (informational, carried from u1): templates/catalog.yaml was deleted in cc2f434 ("legacy block library cleanup"). All four loaders (src.catalog + the three wrappers) gracefully return empty ({"blocks": []} / [] / {}); production behavior is unchanged because no live caller currently requires populated content from that path. Not in IMP-27 scope — IMP-04 (Phase R' / Phase Z catalog re-introduction axis) is the natural home for any re-population decision. Already filed as F-1 in r1.
F-2 (informational, new in u4): src/renderer.py still has live OrderedDict / Path / Any / Jinja imports, but the legacy _CATALOG_MTIME rename to per-projection keys reveals that the renderer-local projection caches duplicate a pattern that could itself be hoisted into src.catalog (e.g. a generic projection_cache(name, builder) helper). Not in IMP-27 scope — IMP-27 K5 axis is loader unification only; projection-cache helper extraction is a candidate for a follow-up cleanup axis if src.renderer and any future projection consumer end up sharing >2 such caches. Filing for awareness only; do not open a new issue unless triggered.
[Claude #6] Stage 3 code-edit Round #4 — IMP-27 K5
Executing unit: u4
■ unit_executed
- id: u4
- summary: Refactor `renderer._load_catalog_map` and `_load_catalog_map_with_variants` to consume the shared root catalog via `src.catalog.load_blocks()`, keep renderer-local projection caches, and key projection invalidation off `src.catalog.get_catalog_mtime()`. Drop `renderer.CATALOG_PATH`, the `yaml` import, and the legacy single `_CATALOG_MTIME` module global; replace with per-projection keys `_CATALOG_MAP_MTIME` / `_CATALOG_VARIANT_MAP_MTIME`.
■ files_changed
- M `D:\ad-hoc\kei\design_agent\src\renderer.py` (+39 / -49, net -10 lines per `git diff --stat`)
- M `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py` (+109, fixture extended + 5 new u4 tests appended; file remains untracked from u1)
■ diff_summary
- `src/renderer.py`:
- Header import block:
- REMOVED `import yaml` (no remaining `yaml.*` call site in this module).
- ADDED `from src import catalog as _catalog_mod` (single delegation entry point — mirrors u2 wiring at `block_reference.py:30` and u3 wiring at `block_selector.py`).
- `from collections import OrderedDict`, `from pathlib import Path`, `from typing import Any`, `from jinja2 import Environment, FileSystemLoader`, and `logger` retained (still used by `TEMPLATES_DIR` L21, `STATIC_DIR` L22, `OrderedDict` group grouping later in the file, Jinja env, and type hints).
- Module-level state (formerly L23, L28~L30, L65~L66 → now L20~L26):
- REMOVED `CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"` (path lives in `src.catalog.CATALOG_PATH` only).
- REMOVED `_CATALOG_MTIME: float = 0.0` (single legacy key — replaced by per-projection keys so the two projections invalidate independently and correctly under `get_catalog_mtime()` keying).
- ADDED `_CATALOG_MAP_MTIME: float = 0.0` and `_CATALOG_VARIANT_MAP_MTIME: float = 0.0` (renderer-local projection cache keys; values come from `_catalog_mod.get_catalog_mtime()`).
- `_CATALOG_MAP: dict[str, str] | None = None` retained (projection cache).
- `_CATALOG_VARIANT_MAP: dict[str, str] | None = None` retained (projection cache); moved up next to its mtime key (formerly L65~L66, now L25~L26) so both projection caches sit together.
- Section header comment updated: `catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)` → `id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)`.
- `_load_catalog_map() -> dict[str, str]` (formerly L32~L62, now L29~L55):
- Body shrunk to projection-only: calls `_catalog_mod.load_blocks()` for raw data and `_catalog_mod.get_catalog_mtime()` for the cache key; projection map rebuild iterates the returned `list[dict]` and writes `id → template`.
- File I/O (`open(CATALOG_PATH, ...)`, `yaml.safe_load`, the missing-file warning, the exception swallowing) fully removed — those concerns now live in `src.catalog.load_root_catalog`.
- Return contract unchanged: `dict[str, str]` keyed by block id. Logging line `catalog.yaml 로드: {N}개 블록 매핑` preserved (callers / log monitors unaffected).
- `_load_catalog_map_with_variants() -> dict[str, str]` (formerly L69~L97, now L58~L80):
- Same projection-only pattern: consumes `_catalog_mod.load_blocks()` directly. Removed the redundant `_load_catalog_map()` pre-call (the variant cache now keys off `get_catalog_mtime()` independently, so the side-effectful "force the other cache to refresh" trick is no longer needed).
- Removed the second `open(CATALOG_PATH, ...)` + `yaml.safe_load` block — single shared file read enforced by `src.catalog`.
- Return contract unchanged: `dict[str, str]` keyed by `"block_id--variant_id"`, excluding `default` variants. Verified by `test_renderer_load_catalog_map_with_variants_returns_compound_key_dict` below.
- `tests/test_catalog_shared_loader.py`:
- Fixture extended (lines 22~38): `fixture-block-b` now carries `variants` with a `default` and a `compact` entry, enabling the variant projection test to exercise the `default`-exclusion rule (preserves pre-IMP-27 behavior in `renderer._load_catalog_map_with_variants`).
- `_reset_renderer_projection_cache()` helper added (lines 47~53) and called from the `fixture_catalog_path` fixture (line 65) so each test starts with both renderer-local projection caches cleared. Without this, the module-level state would leak across tests.
- 5 new u4 tests appended after the u3 block (lines 313~421):
- `test_renderer_load_catalog_map_returns_id_to_template_dict` — verifies `id → template` projection contract (matches existing callers `_resolve_template_path` at L135 of the new renderer).
- `test_renderer_load_catalog_map_with_variants_returns_compound_key_dict` — verifies `"id--variant" → template` projection, with explicit assertion that `default` variants are excluded.
- `test_renderer_shares_cache_with_shared_loader` — monkeypatches `src.catalog.yaml.safe_load` to count file reads; asserts that `renderer._load_catalog_map()` + `renderer._load_catalog_map_with_variants()` + `catalog.load_root_catalog()` together produce exactly 1 file read. This is the regression guard for "renderer must not reopen the catalog file".
- `test_renderer_projection_invalidates_when_shared_mtime_changes` — rewrites the fixture file, bumps mtime, resets `src.catalog._catalog_cache`, and asserts that `renderer._load_catalog_map()` returns the new contents. Locks the contract "renderer projection cache keyed off `src.catalog.get_catalog_mtime()`".
- `test_renderer_has_no_private_catalog_path_or_yaml` — explicit `hasattr` guards against re-introduction of `CATALOG_PATH`, `yaml`, and the legacy `_CATALOG_MTIME` single-key.
■ verification (scope = u4 only)
- `python -m pytest tests/test_catalog_shared_loader.py -q` → **23 passed in 0.14s** (10 u1 + 4 u2 + 4 u3 + 5 u4).
- `python -c "from src.renderer import _load_catalog_map, _load_catalog_map_with_variants; m=_load_catalog_map(); v=_load_catalog_map_with_variants()"` → resolves; both callable. Logged the expected `catalog.yaml 미발견` warning (current repo state: `templates/catalog.yaml` deleted in cc2f434, same precondition as u1 fixture-mode tests).
- `python -c "import src.renderer as r; for n in ('CATALOG_PATH','yaml','_CATALOG_MTIME','_CATALOG_MAP_MTIME','_CATALOG_VARIANT_MAP_MTIME'): print(n, hasattr(r,n))"` → `CATALOG_PATH False`, `yaml False`, `_CATALOG_MTIME False`, `_CATALOG_MAP_MTIME True`, `_CATALOG_VARIANT_MAP_MTIME True`. Confirms scope-lock: legacy state gone, new per-projection mtime keys present.
- `git diff --stat -- src/renderer.py` → `1 file changed, 39 insertions(+), 49 deletions(-)`.
- `git status --short` for this unit: `M src/renderer.py`, `?? tests/test_catalog_shared_loader.py` (untracked since u1). No other tracked files modified.
■ scope-lock verification
- Only renderer's two map functions touched. No edits to `src/catalog.py`, `src/block_reference.py`, `src/block_selector.py`, `src/fit_verifier.py`, `src/space_allocator.py`, `src/pipeline.py`.
- Renderer call sites of `_load_catalog_map` (L77 → now L126 region inside `_resolve_template_path`) and `_load_catalog_map_with_variants` (L126 region) preserved as no-arg calls returning `dict[str, str]` — verified by re-grep `_load_catalog_map\(` in `src/renderer.py` → unchanged signatures at the call site.
- catalog.yaml schema/path frozen.
- No runtime AI path touched. Phase Z 22-step pipeline untouched.
- PZ-1 isolation preserved: renderer is Phase R' surface; the shared loader `src.catalog` introduced in u1 is loader-only and pulls no Phase R' rendering symbols.
■ remaining_units
- (none — u1, u2, u3, u4 all executed across rounds 1~4)
■ follow_up_issue_candidates
- F-1 (informational, carried from u1): `templates/catalog.yaml` was deleted in cc2f434 ("legacy block library cleanup"). All four loaders (`src.catalog` + the three wrappers) gracefully return empty (`{"blocks": []}` / `[]` / `{}`); production behavior is unchanged because no live caller currently requires populated content from that path. **Not in IMP-27 scope** — IMP-04 (Phase R' / Phase Z catalog re-introduction axis) is the natural home for any re-population decision. Already filed as F-1 in r1.
- F-2 (informational, new in u4): `src/renderer.py` still has live `OrderedDict` / `Path` / `Any` / Jinja imports, but the legacy `_CATALOG_MTIME` rename to per-projection keys reveals that the renderer-local projection caches duplicate a pattern that could itself be hoisted into `src.catalog` (e.g. a generic `projection_cache(name, builder)` helper). **Not in IMP-27 scope** — IMP-27 K5 axis is loader unification only; projection-cache helper extraction is a candidate for a follow-up cleanup axis if `src.renderer` and any future projection consumer end up sharing >2 such caches. Filing for awareness only; **do not** open a new issue unless triggered.
Verdict: u4 is correct and scope-locked. All planned Stage 3 implementation units are now complete.
Verified unit:
u4 delegates src.renderer._load_catalog_map() and src.renderer._load_catalog_map_with_variants() to the shared catalog loader through src.catalog.load_blocks().
u4 preserves both renderer projection contracts: dict[id, template_path] and dict["id--variant", template_path].
u4 keeps renderer-local projection caches while keying invalidation from src.catalog.get_catalog_mtime().
src.renderer no longer imports yaml, no longer defines CATALOG_PATH, and no longer uses the legacy single _CATALOG_MTIME global.
The focused u4 tests were added to tests/test_catalog_shared_loader.py and pass together with the u1, u2, and u3 tests.
Scope qualification:
src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py call sites remain untouched.
No templates/catalog.yaml content was changed.
I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
inline Python smoke: monkeypatch src.catalog.CATALOG_PATH to a temp catalog, call renderer._load_catalog_map(), rewrite the catalog with a newer mtime, call renderer._load_catalog_map() again, and confirm the projection changes from {'a': 'blocks/a.html'} to {'b': 'blocks/b.html'}
Files checked:
src.renderer imports from src import catalog as _catalog_mod.
_load_catalog_map() -> dict[str, str] calls _catalog_mod.load_blocks() and _catalog_mod.get_catalog_mtime().
_load_catalog_map_with_variants() -> dict[str, str] calls _catalog_mod.load_blocks() and _catalog_mod.get_catalog_mtime().
Renderer projection call sites remain internal to src.renderer.
src.renderer has no import yaml, no yaml.* call, no CATALOG_PATH, and no _CATALOG_MTIME.
git status --short -- ... reports only the expected IMP-27 files changed or untracked: src/catalog.py, tests/test_catalog_shared_loader.py, src/block_reference.py, src/block_selector.py, and src/renderer.py.
Remaining units: none
FINAL_CONSENSUS: YES
[Codex #6] Stage 3 code-edit review IMP-27 K5
Verdict: u4 is correct and scope-locked. All planned Stage 3 implementation units are now complete.
Verified unit:
- u4 delegates `src.renderer._load_catalog_map()` and `src.renderer._load_catalog_map_with_variants()` to the shared catalog loader through `src.catalog.load_blocks()`.
- u4 preserves both renderer projection contracts: `dict[id, template_path]` and `dict["id--variant", template_path]`.
- u4 keeps renderer-local projection caches while keying invalidation from `src.catalog.get_catalog_mtime()`.
- `src.renderer` no longer imports `yaml`, no longer defines `CATALOG_PATH`, and no longer uses the legacy single `_CATALOG_MTIME` global.
- The focused u4 tests were added to `tests/test_catalog_shared_loader.py` and pass together with the u1, u2, and u3 tests.
Scope qualification:
- `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` call sites remain untouched.
- No `templates/catalog.yaml` content was changed.
- I did not run full pytest because the current Stage 3 task requires verification only for the single executed unit.
=== EVIDENCE ===
Commands run:
- `git diff -- src/renderer.py tests/test_catalog_shared_loader.py`
- `rg -n "import yaml|from src import catalog|CATALOG_PATH|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|def _load_catalog_map|def _load_catalog_map_with_variants|yaml\.|load_blocks\(|get_catalog_mtime\(" src/renderer.py tests/test_catalog_shared_loader.py`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\renderer.py' -TotalCount 180`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'`
- `python -m pytest tests/test_catalog_shared_loader.py -q`
- `python -c "from src.renderer import _load_catalog_map, _load_catalog_map_with_variants; import inspect; print(inspect.signature(_load_catalog_map)); print(inspect.signature(_load_catalog_map_with_variants))"`
- `rg -n "_load_catalog_map\(|_load_catalog_map_with_variants\(" src/renderer.py src tests scripts`
- `Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\catalog.py'`
- `git status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yaml`
- inline Python smoke: monkeypatch `src.catalog.CATALOG_PATH` to a temp catalog, call `renderer._load_catalog_map()`, rewrite the catalog with a newer mtime, call `renderer._load_catalog_map()` again, and confirm the projection changes from `{'a': 'blocks/a.html'}` to `{'b': 'blocks/b.html'}`
Files checked:
- `D:\ad-hoc\kei\design_agent\src\renderer.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- `D:\ad-hoc\kei\design_agent\src\catalog.py`
- `D:\ad-hoc\kei\design_agent\src\fit_verifier.py`
- `D:\ad-hoc\kei\design_agent\src\space_allocator.py`
- `D:\ad-hoc\kei\design_agent\src\pipeline.py`
Test results:
- `python -m pytest tests/test_catalog_shared_loader.py -q` => `23 passed in 0.11s`
Verified facts:
- `src.renderer` imports `from src import catalog as _catalog_mod`.
- `_load_catalog_map() -> dict[str, str]` calls `_catalog_mod.load_blocks()` and `_catalog_mod.get_catalog_mtime()`.
- `_load_catalog_map_with_variants() -> dict[str, str]` calls `_catalog_mod.load_blocks()` and `_catalog_mod.get_catalog_mtime()`.
- Renderer projection call sites remain internal to `src.renderer`.
- `src.renderer` has no `import yaml`, no `yaml.*` call, no `CATALOG_PATH`, and no `_CATALOG_MTIME`.
- `git status --short -- ...` reports only the expected IMP-27 files changed or untracked: `src/catalog.py`, `tests/test_catalog_shared_loader.py`, `src/block_reference.py`, `src/block_selector.py`, and `src/renderer.py`.
Remaining units: none
FINAL_CONSENSUS: YES
u1~u4 4 unit 전수 verify. shared loader (src/catalog.py) + 3 wrapper module + 23 unit case + 365 broader regression case 모두 GREEN.
diff vs Stage 2 plan = 1:1 일치. 산출 파일 = src/catalog.py (NEW, 77 lines), src/block_reference.py (-15 / +6, net -9), src/block_selector.py (-30 / +9, net -21), src/renderer.py (-49 / +39, net -10), tests/test_catalog_shared_loader.py (NEW, 422 lines).
모든 caller signature 보존 — 4 fit_verifier + 3 space_allocator + 1 pipeline import 라인 byte-identical.
catalog.yaml 경로 / mtime cache state 가 src/catalog.py 단독 소유 (block_search.py 는 IMP-27 Stage 1 scope 외; phase_z2_* 의 yaml import 는 다른 catalog 파일들 = frame_contracts / layouts / region_layouts / display_strategies, IMP-27 templates/catalog.yaml 과 별 데이터).
다른 catalog (frame_contracts / layouts / region_layouts / display_strategies)
다른 catalog
—
—
RULE 7 (no hardcoding) PASS — 경로/cache 가 process-level 단일 owner 로 통합되었고 wrapper 는 1-line delegation.
6. Scope-lock guardrail (RULE 4)
수정 파일 = src/catalog.py (NEW) + src/block_reference.py + src/block_selector.py + src/renderer.py + tests/test_catalog_shared_loader.py (NEW). Stage 2 plan 의 5 path 와 정확 일치.
catalog.yaml 콘텐츠 / Phase Z 22-step pipeline / fit_verifier / space_allocator / pipeline 무변경. 의도된 unchanged scope 보존.
AI isolation contract: shared loader = deterministic code, 어떤 단계에서도 AI 호출 도입 없음.
7. Out-of-scope follow-up 후보 (Stage 3 exit report에 명시 — 본 verify에서 작업 X)
src/fit_verifier.py / src/space_allocator.py / src/pipeline.py 는 본 issue Stage 1 의 scope_lock 에서 명시적 제외 — caller만, 자체 로딩 코드 없음.
tests/test_catalog_shared_loader.py tracking 여부 = Stage 5 commit boundary 질문 (verify 단계 결정 사항 아님).
Scope-qualified verification completed for the IMP-27 commit scope. I found no blocking discrepancy in the implementation, signatures, call-site preservation, catalog path ownership, or tests. One non-blocking bookkeeping correction: the previous raw line-count claim for the two new files does not reproduce in this workspace (src/catalog.py = 76 lines, tests/test_catalog_shared_loader.py = 421 lines by Get-Content), so I am not using that count as a pass criterion.
python -m pytest tests/test_catalog_shared_loader.py -v: 23 passed in 0.12s.
python -m pytest tests/ -q --ignore=tests/matching: 365 passed in 37.80s.
Verified facts:
src.catalog is the shared templates/catalog.yaml file-read + mtime-cache owner for the IMP-27 path, with load_root_catalog(), load_blocks(), get_block_by_id(), and get_catalog_mtime().
src.block_reference._load_catalog() delegates to src.catalog.load_blocks() and preserves () -> list[dict].
src.block_reference._get_block_by_id() delegates to src.catalog.get_block_by_id(block_id) and preserves (block_id: str) -> dict | None.
src.block_selector.load_catalog() delegates to src.catalog.load_root_catalog() and preserves () -> dict.
src.block_selector._get_block_by_id() delegates to src.catalog.get_block_by_id(block_id, catalog) and preserves (block_id: str, catalog: dict) -> dict | None.
src.renderer._load_catalog_map() and _load_catalog_map_with_variants() consume src.catalog.load_blocks() and keep renderer-local projection caches keyed by src.catalog.get_catalog_mtime().
src.renderer no longer owns CATALOG_PATH, import yaml, or legacy _CATALOG_MTIME; it now owns _CATALOG_MAP_MTIME and _CATALOG_VARIANT_MAP_MTIME projection keys.
git diff -- templates/catalog.yaml src/fit_verifier.py src/space_allocator.py src/pipeline.py is empty, so catalog data and the external call-site modules remain unchanged.
The grep enumeration still shows the expected _load_catalog() callers in fit_verifier.py, space_allocator.py, pipeline.py, internal block_reference.py, internal block_selector.py, and renderer projection callers; signatures are unchanged.
block_search.py and phase_z2_* catalog references found by grep are outside this IMP-27 binding scope and point to separate catalog concerns.
FINAL_CONSENSUS: YES
[Codex #7] Stage 4 test-verify IMP-27 K5 Round #1
Decision: PASS; commit OK signal.
Scope-qualified verification completed for the IMP-27 commit scope. I found no blocking discrepancy in the implementation, signatures, call-site preservation, catalog path ownership, or tests. One non-blocking bookkeeping correction: the previous raw line-count claim for the two new files does not reproduce in this workspace (`src/catalog.py` = 76 lines, `tests/test_catalog_shared_loader.py` = 421 lines by `Get-Content`), so I am not using that count as a pass criterion.
=== EVIDENCE ===
Commands run:
- `git status --short`
- `git diff -- src/catalog.py src/block_reference.py src/block_selector.py src/renderer.py tests/test_catalog_shared_loader.py`
- `rg -n "def _load_catalog|def load_catalog|def _load_catalog_map|def _load_catalog_map_with_variants|def _get_block_by_id|CATALOG_PATH|import yaml|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|load_blocks\(|get_catalog_mtime\(" src tests/test_catalog_shared_loader.py`
- `Get-Content -LiteralPath 'src\catalog.py'`
- `rg -n "from src\.block_reference import _load_catalog|_load_catalog\(|load_catalog\(|_get_block_by_id\(|_load_catalog_map\(|_load_catalog_map_with_variants\(" src\fit_verifier.py src\space_allocator.py src\pipeline.py src\block_reference.py src\block_selector.py src\renderer.py`
- `git diff -- src/fit_verifier.py src/space_allocator.py src/pipeline.py`
- `python -m pytest tests/test_catalog_shared_loader.py -v`
- `python -m pytest tests/ -q --ignore=tests/matching`
- `python -` signature inspection for the six public/private wrapper functions
- `git diff --stat -- src/block_reference.py src/block_selector.py src/renderer.py`
- `Get-Content` line counts for `src\catalog.py` and `tests\test_catalog_shared_loader.py`
- `git diff -- templates/catalog.yaml src/fit_verifier.py src/space_allocator.py src/pipeline.py`
Files checked:
- `D:\ad-hoc\kei\design_agent\src\catalog.py`
- `D:\ad-hoc\kei\design_agent\src\block_reference.py`
- `D:\ad-hoc\kei\design_agent\src\block_selector.py`
- `D:\ad-hoc\kei\design_agent\src\renderer.py`
- `D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py`
- `D:\ad-hoc\kei\design_agent\src\fit_verifier.py`
- `D:\ad-hoc\kei\design_agent\src\space_allocator.py`
- `D:\ad-hoc\kei\design_agent\src\pipeline.py`
- `D:\ad-hoc\kei\design_agent\templates\catalog.yaml`
Test results:
- `python -m pytest tests/test_catalog_shared_loader.py -v`: 23 passed in 0.12s.
- `python -m pytest tests/ -q --ignore=tests/matching`: 365 passed in 37.80s.
Verified facts:
- `src.catalog` is the shared `templates/catalog.yaml` file-read + mtime-cache owner for the IMP-27 path, with `load_root_catalog()`, `load_blocks()`, `get_block_by_id()`, and `get_catalog_mtime()`.
- `src.block_reference._load_catalog()` delegates to `src.catalog.load_blocks()` and preserves `() -> list[dict]`.
- `src.block_reference._get_block_by_id()` delegates to `src.catalog.get_block_by_id(block_id)` and preserves `(block_id: str) -> dict | None`.
- `src.block_selector.load_catalog()` delegates to `src.catalog.load_root_catalog()` and preserves `() -> dict`.
- `src.block_selector._get_block_by_id()` delegates to `src.catalog.get_block_by_id(block_id, catalog)` and preserves `(block_id: str, catalog: dict) -> dict | None`.
- `src.renderer._load_catalog_map()` and `_load_catalog_map_with_variants()` consume `src.catalog.load_blocks()` and keep renderer-local projection caches keyed by `src.catalog.get_catalog_mtime()`.
- `src.renderer` no longer owns `CATALOG_PATH`, `import yaml`, or legacy `_CATALOG_MTIME`; it now owns `_CATALOG_MAP_MTIME` and `_CATALOG_VARIANT_MAP_MTIME` projection keys.
- `git diff -- templates/catalog.yaml src/fit_verifier.py src/space_allocator.py src/pipeline.py` is empty, so catalog data and the external call-site modules remain unchanged.
- The grep enumeration still shows the expected `_load_catalog()` callers in `fit_verifier.py`, `space_allocator.py`, `pipeline.py`, internal `block_reference.py`, internal `block_selector.py`, and renderer projection callers; signatures are unchanged.
- `block_search.py` and `phase_z2_*` catalog references found by grep are outside this IMP-27 binding scope and point to separate catalog concerns.
FINAL_CONSENSUS: YES
src/fit_verifier.py, src/space_allocator.py, src/pipeline.py, templates/catalog.yaml — untouched (post-commit git status shows no M for these).
All tests/matching/**, samples/**, .orchestrator/**, chromedriver/**, templates/phase_z2/**, scripts/**, root-level orchestrator script — left untracked (carried over from prior working tree).
■ Verification (pre-commit, carried over from Stage 4)
pytest tests/test_catalog_shared_loader.py -v → 23/23 PASS in 0.13s.
pytest tests/ -q --ignore=tests/matching → 365/365 PASS in 38.10s.
No new tests run at Stage 5 (commit only; no source change between Stage 4 verify and this push).
■ Guardrails honored
RULE 6 (git add specific files only): named 5 paths explicitly, no git add -A / ..
Scope-lock: caller signatures in fit_verifier / space_allocator / pipeline byte-identical (git diff empty for those files).
Memory reference_design_agent_remotes: pushed to both slide2 (Gitea mirror = naming mismatch C.E.L_Slide_test2) and origin (GitHub). gitea remote (= _Geulbeot, different project) intentionally NOT pushed.
candidate for follow-up issue (Phase R' cleanup or Phase Z catalog 확장 trigger axis).
src/block_search.pyyaml import — separate axis (block search index, not catalog loader).
phase_z2_* catalog imports — separate catalog data domain (frame_contracts / layouts / region_layouts / display_strategies), not templates/catalog.yaml.
tests/test_catalog_shared_loader.py tracking — already committed at 909bf75 (no decision pending).
These do not block IMP-27 K5 closure — issue scope was explicitly "3 module" (block_reference + block_selector + renderer). The above are candidates for a successor cleanup issue.
Labels / milestone
milestone reference in body: B-4 §4 cleanup (3) [#92] — milestone update is user-owned (orchestrator does not auto-set labels).
Close decision
READY TO CLOSE (issue close left to user per orchestrator convention).
Rationale:
All goal axes from issue body satisfied (3-module catalog loader dedup + 2-module _get_block_by_id dedup + catalog.yaml untouched + return contracts preserved + caller signatures byte-identical).
Commit 909bf75 present on both remotes (GitHub origin + Gitea slide2) at refs/heads/main.
All review-loop checkboxes satisfied across Claude #1~#8 + Codex #1~#8.
Out-of-scope axes (catalog upstream consumers, block_search, phase_z2) explicitly enumerated for follow-up; not blocking.
I independently verified the Stage 5 commit/push state and the Stage 6 closure criteria from the local repository and remotes. The implemented result matches the issue scope: shared catalog file-read + mtime-cache ownership is centralized in src/catalog.py, the three target modules delegate to it, _get_block_by_id is canonicalized through src.catalog.get_block_by_id, and the public wrapper signatures/return contracts remain preserved.
Scope guard also holds: templates/catalog.yaml, src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py have no diff in the IMP-27 commit range. Both origin and slide2 point refs/heads/main at 909bf75edc633111b325175a2d3e40639def62be, and the parent-to-HEAD range contains exactly one commit.
HEAD is 909bf75edc633111b325175a2d3e40639def62be with parent 2896bb691c421bbae9bfa426178c92496d10dabc.
HEAD title is refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup.
Author/time is kyeongmin, 2026-05-20 19:31:26 +0900.
git show --name-status lists exactly src/catalog.py A, tests/test_catalog_shared_loader.py A, and modifications to src/block_reference.py, src/block_selector.py, src/renderer.py.
Shortstat is 5 files changed, 550 insertions(+), 101 deletions(-).
Commit range 2896bb691c421bbae9bfa426178c92496d10dabc..HEAD contains exactly 1 commit.
origin refs/heads/main and slide2 refs/heads/main both resolve to 909bf75edc633111b325175a2d3e40639def62be.
src/catalog.py defines the shared CATALOG_PATH, yaml.safe_load, _catalog_cache, _catalog_mtime, load_root_catalog, load_blocks, get_block_by_id, and get_catalog_mtime ownership.
src/block_reference.py delegates _load_catalog() to _catalog_mod.load_blocks() and _get_block_by_id(block_id) to _catalog_mod.get_block_by_id(block_id).
src/block_selector.py delegates load_catalog() to _catalog_mod.load_root_catalog() and _get_block_by_id(block_id, catalog) to _catalog_mod.get_block_by_id(block_id, catalog).
src/renderer.py delegates projection inputs to _catalog_mod.load_blocks() and projection invalidation to _catalog_mod.get_catalog_mtime() while keeping renderer-local projection caches.
Out-of-scope files templates/catalog.yaml, src/fit_verifier.py, src/space_allocator.py, and src/pipeline.py are unchanged in the IMP-27 commit range.
Targeted shared-loader regression rerun: 23 passed in 0.11s.
Full pytest was not rerun at Stage 6, consistent with the instruction not to run full pytest unless explicitly required; Stage 4 binding report remains the source for the broader 365/365 regression result.
FINAL_CONSENSUS: YES
[Codex #9] Stage 6 final-close verify IMP-27 K5 Round #1
Verdict: PASS. Close OK signal.
I independently verified the Stage 5 commit/push state and the Stage 6 closure criteria from the local repository and remotes. The implemented result matches the issue scope: shared catalog file-read + mtime-cache ownership is centralized in `src/catalog.py`, the three target modules delegate to it, `_get_block_by_id` is canonicalized through `src.catalog.get_block_by_id`, and the public wrapper signatures/return contracts remain preserved.
Scope guard also holds: `templates/catalog.yaml`, `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` have no diff in the IMP-27 commit range. Both `origin` and `slide2` point `refs/heads/main` at `909bf75edc633111b325175a2d3e40639def62be`, and the parent-to-HEAD range contains exactly one commit.
=== EVIDENCE ===
Commands run:
- `git log -1 --format='%H%n%P%n%s%n%an%n%ai'`
- `git show --shortstat --format= HEAD`
- `git show --name-status --format= HEAD`
- `git rev-list --count 2896bb691c421bbae9bfa426178c92496d10dabc..HEAD`
- `git ls-remote origin refs/heads/main`
- `git ls-remote slide2 refs/heads/main`
- `rg -n "def _load_catalog|def _get_block_by_id|from src import catalog|import yaml|CATALOG_PATH|_catalog_cache|_catalog_mtime" src\block_reference.py src\block_selector.py`
- `rg -n "def _load_catalog_map|def _load_catalog_map_with_variants|from src import catalog|import yaml|CATALOG_PATH|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|load_blocks\(|get_catalog_mtime\(" src\renderer.py`
- `git diff HEAD -- src\fit_verifier.py src\space_allocator.py src\pipeline.py templates\catalog.yaml`
- `git diff HEAD^ HEAD -- templates\catalog.yaml src\fit_verifier.py src\space_allocator.py src\pipeline.py`
- `python -c "import inspect; import src.block_reference as br; import src.block_selector as bs; import src.renderer as r; ..."`
- `python -m pytest tests/test_catalog_shared_loader.py -q`
Files checked:
- `src/catalog.py`
- `src/block_reference.py`
- `src/block_selector.py`
- `src/renderer.py`
- `tests/test_catalog_shared_loader.py`
- `templates/catalog.yaml`
- `src/fit_verifier.py`
- `src/space_allocator.py`
- `src/pipeline.py`
Commit SHA:
- `909bf75edc633111b325175a2d3e40639def62be`
Verified facts:
- HEAD is `909bf75edc633111b325175a2d3e40639def62be` with parent `2896bb691c421bbae9bfa426178c92496d10dabc`.
- HEAD title is `refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup`.
- Author/time is `kyeongmin`, `2026-05-20 19:31:26 +0900`.
- `git show --name-status` lists exactly `src/catalog.py` A, `tests/test_catalog_shared_loader.py` A, and modifications to `src/block_reference.py`, `src/block_selector.py`, `src/renderer.py`.
- Shortstat is `5 files changed, 550 insertions(+), 101 deletions(-)`.
- Commit range `2896bb691c421bbae9bfa426178c92496d10dabc..HEAD` contains exactly 1 commit.
- `origin refs/heads/main` and `slide2 refs/heads/main` both resolve to `909bf75edc633111b325175a2d3e40639def62be`.
- `src/catalog.py` defines the shared `CATALOG_PATH`, `yaml.safe_load`, `_catalog_cache`, `_catalog_mtime`, `load_root_catalog`, `load_blocks`, `get_block_by_id`, and `get_catalog_mtime` ownership.
- `src/block_reference.py` delegates `_load_catalog()` to `_catalog_mod.load_blocks()` and `_get_block_by_id(block_id)` to `_catalog_mod.get_block_by_id(block_id)`.
- `src/block_selector.py` delegates `load_catalog()` to `_catalog_mod.load_root_catalog()` and `_get_block_by_id(block_id, catalog)` to `_catalog_mod.get_block_by_id(block_id, catalog)`.
- `src/renderer.py` delegates projection inputs to `_catalog_mod.load_blocks()` and projection invalidation to `_catalog_mod.get_catalog_mtime()` while keeping renderer-local projection caches.
- Preserved signatures verified: `block_reference._load_catalog()`, `block_reference._get_block_by_id(block_id)`, `block_selector.load_catalog()`, `block_selector._get_block_by_id(block_id, catalog)`, `renderer._load_catalog_map()`, `renderer._load_catalog_map_with_variants()`.
- Out-of-scope files `templates/catalog.yaml`, `src/fit_verifier.py`, `src/space_allocator.py`, and `src/pipeline.py` are unchanged in the IMP-27 commit range.
- Targeted shared-loader regression rerun: `23 passed in 0.11s`.
- Full pytest was not rerun at Stage 6, consistent with the instruction not to run full pytest unless explicitly required; Stage 4 binding report remains the source for the broader `365/365` regression result.
FINAL_CONSENSUS: YES
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
related module: §2.8
renderer+ §2.10block_reference+block_selector(3 module)source: INSIGHT-MAP §5 K5
priority: ↓ low (future)
scope:
_get_block_by_id중복 제거 (3 module)guardrail / validation:
trigger axis: Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04)
milestone: B-4 §4 cleanup (3) [#92]
cross-ref:
review loop:
[Claude #1] Stage 1 problem-review — IMP-27 K5 (catalog 로드 +
_get_block_by_id중복 cleanup, 3 module)1. Root cause
Three Phase Q modules each grew their own catalog loader (and two of them grew their own
_get_block_by_id) during Phase Q evolution. INSIGHT-MAP §5 K5 (docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151) and PHASE-Q-AUDIT.md §2.10 K5 (docs/architecture/PHASE-Q-AUDIT.md:1087) catch this as a cleanup-only 별 axis ("Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 중복 제거 별도 판단"). Verified factually per RULE 5 (value + path + upstream + unknown):1-a. Three loaders, not three duplicates — shapes differ
src/block_reference.py:110_load_catalog() -> list[dict]blockslistsrc/block_reference.py(5×: L260, L324, L462, L657, L666, L713),src/fit_verifier.py(4×: L131, L840, L985, L1004),src/space_allocator.py(3×: L34, L390, L786),src/pipeline.py:228src/block_selector.py:57load_catalog() -> dict{"blocks": [...], ...})src/block_selector.pyL109, L203src/renderer.py:32_load_catalog_map() -> dict[str, str]id → template_pathmapsrc/renderer.pyL77, L126, L135src/renderer.py:69_load_catalog_map_with_variants() -> dict[str, str]"id--variant" → template_pathmapsrc/renderer.pyL126The renderer pair returns a derived projection (id → template-path string), not the catalog list. Only
block_reference._load_catalogandblock_selector.load_catalogreturn the catalog itself, and even there the shape differs (list vs root dict). So "3-module duplication" is true at the file-read + mtime-cache layer only; the return contract duplication is 2-module.1-b. Two
_get_block_by_id, different signaturessrc/block_reference.py:124_get_block_by_id(block_id: str) -> dict | None_load_catalog()callsrc/block_selector.py:78_get_block_by_id(block_id: str, catalog: dict) -> dict | Nonesrc/renderer.pyhas no_get_block_by_id— it does template-path lookup against the derived map (_load_catalog_map) instead. So the issue body's "3 module" framing for_get_block_by_idis one module off: the actual duplication is 2 module (block_reference + block_selector), and renderer is co-listed only for the loader axis.1-c. Critical contextual fact —
templates/catalog.yamlwas deleted on 2026-05-08Commit
cc2f434("cleanup: legacy templates/blocks + figma_to_html_agent block-tests / 옛 docs 정리", 2026-05-08) deletedtemplates/catalog.yaml(3605 lines), with this rationale in the commit message:templates/catalog.yaml — 위 block library 의 catalog (의존성과 같이 폐기). ... Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증).Verified:
ls templates/catalog.yaml→ not found (current main HEAD =2896bb6).git log -- templates/catalog.yamlshows last touch =cc2f434(deletion).src/block_reference.py:112→path = TEMPLATES_DIR / "catalog.yaml"src/block_selector.py:22→CATALOG_PATH = Path("templates/catalog.yaml")src/renderer.py:23→CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"Failure-mode mismatch among the three loaders against the deleted file:
block_selector.load_catalog(L61–63):if not CATALOG_PATH.exists(): return {"blocks": []}— graceful empty.renderer._load_catalog_map(L39, L47, L60):if CATALOG_PATH.exists()guarded + warning log — graceful empty.block_reference._load_catalog(L113): unconditionalpath.stat().st_mtime— FileNotFoundError on first invocation. No.exists()guard.The third loader (block_reference) is the de-facto-shared utility (called by fit_verifier ×4, space_allocator ×3, pipeline ×1), so an actual runtime call against the dead path would raise. The cleanup commit's grep-verification (
Phase Z runtime / V4 catalog 영향 0) implies the lazy-import call sites underphase_z2_*runtime never fire in normal Phase Z path. That implication needs re-verification in Stage 2 plan; pending that, the cleanup framing must treat this as a dead-path consolidation, not a live-loader unification.1-d. Trigger-axis status (soft link IMP-04)
Issue trigger reads "Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04)". Verified:
docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48) = implemented. ✓ trigger #2 met on paper.frame_contracts.yaml+frame_partialsis schema-divergent from the deleted Phase Qcatalog.yaml(block id + variant) — established by IMP-24 (block_reference.py/block_selector.py간접 catalog 로딩 / block 검색 패턴 reference; Phase Q catalog (block id + variant) ≠ Phase Z catalog (frame contract + frame partial) schema; commitb1897c0). So IMP-04 activation does not auto-justify Phase Q catalog-loader cleanup; the schemas don't connect.documented (deferred)(docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68); activation gate (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live) is incomplete — B4 frame_selection evidence integration is pending (CLAUDE.md "B4 frame_selection evidence integration pending"). Phase R' is quiescent, not retired.→ Strict reading: neither activation gate is fully met. The closing rationale for IMP-27 in this round is the catalog-file-deleted ground reality (§1-c), not the IMP-04 / Phase R' cleanup gate. That ground reality recasts the scope from "dedup live loaders" to "consolidate / archive dead loaders + reconcile callers". This is a material change vs the issue body and must be acknowledged in Stage 2 plan.
1-e. Caller surface in / out of scope
In K5's 3-module scope but with cross-module caller links:
block_reference._load_catalogcallers — split between Phase R' surface (block_referencehigh-level entriesselect_reference_block/generate_design_reference/select_and_generate_references= Phase R' Archive per §2.10 K1,docs/architecture/PHASE-Q-AUDIT.md:1079) and Phase Q residual-utility surface (fit_verifier._load_catalog4×,space_allocator._load_catalog3×,pipeline._match_by_tagsat L228). The second group is NOT Phase R' archive — it is active utility code that currently would fail-fast against the dead file if reached.block_selector.load_catalogcallers — internal toblock_selectoronly.block_selectoritself is unused in pipeline.py (Phase Q-2 legacy per §2.10 K3,docs/architecture/PHASE-Q-AUDIT.md:1083); onlyformat_candidates_for_promptis consumed bysrc/kei_client.py:454(Kei flow integration, Archive per §2.10 K2).renderer._load_catalog_map/_load_catalog_map_with_variantscallers — internal torenderer.pyonly (called from_resolve_template_pathL108 → invoked byrender_slide_from_html, which is Phase R' Archive per §2.8 auditdocs/architecture/PHASE-Q-AUDIT.md:1345"AI 가 HTML 구조 직접 생성 금지").→ Asymmetric surface: cleanup of
renderer._load_catalog_map/block_selector.load_catalogtouches Archive surface only; cleanup ofblock_reference._load_catalogtouches an active utility used by fit_verifier / space_allocator / pipeline. These two halves need different treatment, not a single "unify loaders" pass.2. Scope-lock proposal (Stage 1 — to be tightened in Stage 2)
src/block_reference.py(loader + id-lookup blocks L107–129),src/block_selector.py(CATALOG_PATH + loader + id-lookup L22–84),src/renderer.py(CATALOG_PATH + 2 loaders L23–97)src/fit_verifier.py(4 import sites: L131, L840, L985, L1004),src/space_allocator.py(3 import sites: L34, L390, L786),src/pipeline.py:228,src/kei_client.py:454phase_z2_*modules (independent Phase Z catalog path:templates/phase_z2/catalog/frame_contracts.yaml)templates/catalog.yamlno longer exists sincecc2f434; "catalog 변경 0" applies only to live catalogs that the scope chooses to read from (e.g.,templates/catalog/blocks.yamlif and only if Stage 2 explicitly chooses that path — undecided)list[dict]vs root-dictvsid→template_path); align_get_block_by_idbetween block_reference + block_selectorOut of scope (must remain untouched):
templates/phase_z2/catalog/frame_contracts.yamland Phase Z partials (IMP-04 axis — different schema).templates/catalog/blocks.yaml(Phase Y/Q' block catalog read bysrc/catalog_blocks.py/src/pipeline_v2.py:89— different schema withsource_frame/recipe_compatslots vs Phase Qid + variant).select_reference_block/generate_design_reference/select_and_generate_references/format_candidates_for_prompt) — Archive markers K1 / K2, no cleanup posture.yaml.safe_load(catalog_path)sites inblock_assembler.py,design_director.py,block_matcher_tfidf.py,block_search.py— explicitly not in K5's "3 module" surface and would expand cleanup scope without §5 cover.tests/test_catalog_invariant.py:22has its own_load_catalog) — test-only fixture, not in K5's 3-module surface.3. Guardrails / validation
templates/catalog/blocks.yamlit materially changes the catalog source — that is a separate design decision flagged in §4 open questions, NOT an automatic move).fit_verifier/space_allocator/pipeline.py:228all iterate_load_catalog()aslist[dict]of block records (verified atsrc/fit_verifier.py:132,src/space_allocator.py:37,392,713,787,825,src/pipeline.py:228+). Any unified loader MUST preservelist[dict]return for the block_reference call sites (or migrate every call site to the new shape in the same commit unit).renderer._load_catalog_mapis consumed only by_resolve_template_path(Phase R' /render_slide_from_htmlArchive). Cleanup of renderer's loader must NOT pull Phase R' Archive entry points into the active Phase Z normal path (PZ-1: AI=0 normal). If the cleanup choice is "unify loaders into a shared module", that shared module must be importable without dragging Phase R' rendering symbols.block_selectorlegacy status preserved — §2.10 K3 (docs/architecture/PHASE-Q-AUDIT.md:1083) typesblock_selectormain filtering (select_block_candidates/select_fallback_candidates) as Phase Q-2 legacy unused with dual-path risk. K5 cleanup must NOT silently reactivate that path (e.g., by routing block_reference callers throughblock_selector.load_catalog's dict-shape).templates/phase_z2/catalog/frame_contracts.yaml. Verified atdocs/architecture/PHASE-Q-AUDIT.md:99(post-IMP-24 commitb1897c0annotated form).templates/catalog/blocks.yaml— different schema, breaks Phase Qblock id + variantcontract), (c) keep loaders as no-op with empty-return guards (block_reference gains the.exists()guard that block_selector / renderer already have). Default Stage 1 proposal: leave the decision to Stage 2 plan; do NOT pre-commit to one option in Stage 1.templates/catalog.yamlabsent — verifiedls templates/catalog.yamlreturns no entry; onlytemplates/catalog/blocks.yamlandtemplates/phase_z2/catalog/frame_contracts.yamlexist (find templates -name "*.yaml").cc2f434(2026-05-08); commit message verified to state "Phase Z runtime / V4 catalog 영향 0 (의존성 grep 으로 사전 검증)"._get_block_by_id중복 (block_reference / block_selector / §2.8 renderer 3 module). Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 중복 제거 별도 판단." — verified atdocs/architecture/PHASE-Q-AUDIT.md:1087.docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151; backlog IMP-27 row atdocs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94(status=pending, priority=↓ low (future), soft link IMP-04).implementedverified atdocs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:48.documented (deferred)verified atdocs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68; 3-cond AND gate with "B4 frame_selection evidence" pending (CLAUDE.md confirms "B4 frame_selection evidence integration pending").Grep _load_catalog\|load_catalog\|_get_block_by_idacrosssrc/(head_limit 0): block_reference_load_catalog6 internal + 4 external (fit_verifier) + 3 external (space_allocator) + 1 external (pipeline) = 14 invocations of the same symbol; block_selectorload_catalog2 internal + 0 external; renderer_load_catalog_map3 internal + 0 external.phase_z2_*.pylazy-importblock_reference._load_catalogindirectly viafit_verifier/space_allocatorreach a runtime call? cc2f434's commit-time grep claims no; Stage 2 plan should re-run that grep at HEAD2896bb6and produce trace evidence.templates/catalog/blocks.yamlschema fit — IF Stage 2 picks option (b) (migrate unified loader to Phase Y catalog), the new shape (source_frame,slots,recipe_compat) does not match Phase Q field expectations (min_height_px,height_cost,category,relation_types,variants) consumed by fit_verifier / space_allocator / pipeline / block_reference. Option (b) likely breaks active utility callers. Recommend Stage 2 default to option (a) or (c).4. Open questions for Stage 2 plan
templates/catalog/blocks.yamlwith adapter, or (c) no-op guards. Recommended default: option (a) + (c) hybrid — remove dead callers in fit_verifier / space_allocator / pipeline (option a) and add.exists()guard to block_reference._load_catalog for any residual lazy imports (option c). This preserves the issue guardrail "catalog 변경 0 — 로드 path 만 통합" by NOT introducing a new live catalog source._get_block_by_idconsolidation contract — if cleanup keeps a no-op block_reference loader,_get_block_by_idreduces to a trivial linear search on the empty list. Two options: drop the helper entirely (option a), or keep as no-op for caller-shape stability (option c). Tied to Q1.block_selectorretirement signal — issue body listsblock_selectorin K5 scope, butblock_selectormain filtering is Phase Q-2 legacy unused (§2.10 K3) and onlyformat_candidates_for_promptis consumed bykei_client.py:454(Kei flow integration Archive per K2). Stage 2 must decide: do we (i) removeblock_selector.load_catalog+_get_block_by_identirely and leave onlyformat_candidates_for_prompt(cleaner Phase R' archive), or (ii) consolidate into a shared loader to keep symmetry?renderer._load_catalog_mapis structurally different from the loader pair (returns id→template_path). Treating it as "duplication" in the same axis as block_reference/block_selector requires either redefining the duplication (file-read + mtime layer only) or splitting the renderer cleanup into a separate sub-axis. Stage 2 should pick one framing.tests/test_catalog_invariant.py:22defines its own local_load_catalog. If the cleanup removes / repoints the 3-module loaders, what test contract validates the consolidated state? Stage 2 plan must list expected test additions / migrations (or document rationale for none).5. Stage 2 plan preview (not executed in Stage 1)
2896bb6to confirm Phase Z normal path does not invoke any of the 3 loaders (close §3 unknown #1).src/or unit test additions).tests/test_catalog_consolidation_*.py.6. Review loop posture
FINAL_CONSENSUS: YES/NObefore proceeding to Stage 2 plan.rewind_targetcandidates:stage_1_review— if scope-lock / guardrails need factual or boundary refinement (e.g., add/remove a caller from the in-scope list; tighten Phase R' isolation language).[Codex #1] Stage 1 problem-review IMP-27
Verdict: scope-lock with correction.
I verified the root cause with grep/find against the current worktree. The IMP-27 cleanup target is real, but it must be worded precisely:
The scoped 3-module duplication is the catalog read/cache/projection layer, not identical return contracts.
src/block_reference.py:110defines_load_catalog() -> list[dict], readstemplates/catalog.yaml, caches by mtime, and returnsdata["blocks"].src/block_selector.py:57definesload_catalog() -> dict, readstemplates/catalog.yaml, caches by mtime, and returns the full root dict.src/renderer.py:32defines_load_catalog_map() -> dict[str, str], readstemplates/catalog.yaml, caches by mtime, and returns an id-to-template projection.src/renderer.py:69defines_load_catalog_map_with_variants() -> dict[str, str], reads the same catalog again for variant template projection after_load_catalog_map()refreshes the cache state._get_block_by_idduplication is 2-module only.src/block_reference.py:124defines_get_block_by_id(block_id: str) -> dict | Noneand calls its own_load_catalog().src/block_selector.py:78defines_get_block_by_id(block_id: str, catalog: dict) -> dict | Noneand searches a caller-supplied catalog.src/renderer.pyhas no_get_block_by_id; it resolves template paths through_load_catalog_map()and_load_catalog_map_with_variants().Direct consumers confirm blast radius is not limited to the three files if
_load_catalog()is changed.src/block_reference.pycalls_load_catalog()internally at lines 126, 260, 657, 666, and 713;_get_block_by_id()is used at lines 324 and 462.src/fit_verifier.pyimportssrc.block_reference._load_catalogat lines 131, 840, 985, and 1004.src/space_allocator.pyimportssrc.block_reference._load_catalogat lines 34, 390, and 786, with later same-function uses at lines 713 and 825.src/pipeline.py:228imports_match_by_tagsand_load_catalog, and calls_load_catalog()at line 280.Current catalog-path fact:
templates/catalog.yamlis missing in the current worktree.Test-Path templates/catalog.yamlreturnedFalse.git log --diff-filter=D --summary -- templates/catalog.yamlshows commitcc2f4340002242cba3ac2b16186b5c330d769816deletedtemplates/catalog.yamlon 2026-05-08, with commit rationale stating Phase Z runtime / V4 catalog impact 0.templates/catalog/blocks.yamlandtemplates/phase_z2/catalog, so any implementation plan must not silently recreate or re-centralize around the deleted legacytemplates/catalog.yamlunless the Stage 2 plan explicitly justifies that compatibility layer.Documentation cross-check matches the issue trigger, with the same wording caveat.
docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151lists K5 as catalog load plus_get_block_by_idduplication acrossblock_reference,block_selector, andrenderer, triggered only by Phase R' cleanup or Phase Z catalog extension.docs/architecture/PHASE-Q-AUDIT.md:1087lists the same K5 cleanup axis.docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:94lists IMP-27 as pending low-priority future cleanup, scoped to duplicate catalog loading acrossblock_reference,block_selector, andrenderer.Scope lock for Stage 2:
templates/catalog.yamlis deleted._get_block_by_idas a 2-module helper duplication, not a renderer concern.templates/catalog.yamlreaders found in other modules such asdesign_director.py,block_assembler.py,block_search.py, andblock_matcher_tfidf.py.=== EVIDENCE ===
Commands run:
rg -n "def _load_catalog|def load_catalog|def _load_catalog_map|def _load_catalog_map_with_variants|def _get_block_by_id|catalog\\.yaml|_load_catalog\\(|load_catalog\\(|_load_catalog_map\\(" src docs templatesTest-Path -LiteralPath 'templates\\catalog.yaml'; Get-ChildItem -LiteralPath 'templates' -Forcegit log --diff-filter=D --summary -- templates/catalog.yamlrg -n "from block_reference import|import block_reference|_load_catalog\\(|_get_block_by_id\\(|load_catalog\\(|_load_catalog_map\\(|_load_catalog_map_with_variants\\(" src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.pySelect-String -Path src/block_reference.py,src/block_selector.py,src/renderer.py -Pattern 'def _load_catalog|def load_catalog|def _get_block_by_id|def _load_catalog_map|def _load_catalog_map_with_variants|CATALOG_PATH|TEMPLATES_DIR' -Context 0,8rg -n "K5|_load_catalog|load_catalog|catalog schema|renderer|block_reference|block_selector|Phase R|Phase Z" docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md docs/architecture/PHASE-Q-AUDIT.md docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.mdSelect-String -Path src/fit_verifier.py,src/space_allocator.py,src/pipeline.py -Pattern 'from block_reference import|import block_reference|_load_catalog|_match_by_tags' -Context 0,2rg -n "_get_block_by_id" src tests docs --glob '!docs/history/**'rg -n "def _load_catalog\\(|def load_catalog\\(|def _load_catalog_map\\(|def _load_catalog_map_with_variants\\(" src --glob '!docs/history/**'rg -n "templates/catalog.yaml|catalog.yaml" tests src --glob '!docs/history/**'Files checked:
src/block_reference.pysrc/block_selector.pysrc/renderer.pysrc/fit_verifier.pysrc/space_allocator.pysrc/pipeline.pydocs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.mddocs/architecture/PHASE-Q-AUDIT.mddocs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.mdtemplates/catalog/blocks.yamltemplates/phase_z2/catalogTest results: Not run; Stage 1 explicitly says NO pytest.
Verified facts:
templates/catalog.yamldoes not exist in the current worktree.cc2f4340002242cba3ac2b16186b5c330d769816deletedtemplates/catalog.yamlon 2026-05-08._get_block_by_id; renderer does not._load_catalog()fromblock_reference.pyhas external callers infit_verifier.py,space_allocator.py, andpipeline.py, so caller compatibility is a Stage 2 requirement.FINAL_CONSENSUS: YES
[Claude #2] Stage 2 simulation-plan — IMP-27 K5 (catalog 로드 + _get_block_by_id 중복 cleanup)
=== ISSUE-BODY AXIS COVERAGE ===
block_reference._load_catalog(list[dict])block_selector.load_catalog(root dict)renderer._load_catalog_map[_with_variants](derived)block_reference._get_block_by_id(no-arg)block_selector._get_block_by_id(catalog-injected)Coverage: every §scope axis maps to ≥1 unit. PASS.
=== KEY DECISIONS (Stage 1 Q1~Q3 resolved) ===
src/catalog.py(block_reference single-responsibility 유지, asymmetric ownership 회피).get_block_by_id(block_id, catalog=None)(catalog-injected 일반형; None 시 자동 load). 두 기존 form → 1-line wrapper.=== IMPLEMENTATION_UNITS ===
summary: Create src/catalog.py shared loader (load_root_catalog, load_blocks, get_block_by_id, get_catalog_mtime) with single mtime cache primitive.
files: [src/catalog.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 45
summary: Delegate block_reference._load_catalog and _get_block_by_id to src.catalog; remove module-level _catalog_cache.
files: [src/block_reference.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 18
summary: Delegate block_selector.load_catalog and _get_block_by_id to src.catalog; remove module-level _catalog_cache/_catalog_mtime.
files: [src/block_selector.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 22
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume src.catalog.load_root_catalog; keep renderer-local projection cache keyed off shared mtime.
files: [src/renderer.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 35
=== PER-UNIT RATIONALE ===
u1 — Sole owner of file-read + mtime cache. API subsumes both existing contracts (list-of-blocks + root-dict + caller-injected get_block_by_id). Test = 5 cases: 4 functions + mtime invalidation.
u2 — block_reference loader/helper → 1-line wrappers. Internal sites L126/260/324/462/657/666/713 byte-identical. External callers fit_verifier 4 / space_allocator 5 / pipeline 1 untouched.
u3 — block_selector loader/helper → 1-line wrappers.
[Q-2] N개 블록info log moves into shared cache-miss path (logged once per file change — observably equivalent).u4 — Projections rebuild on shared-mtime change; cache renderer-side. Sites L77/L126/L135 byte-identical. CATALOG_PATH + projection cache names retained for minimal diff.
=== OUT-OF-SCOPE ===
=== ROLLBACK ===
1 unit = 1 atomic commit. Rollback =
git revert <sha>reverse u4→u3→u2→u1. Call-site contracts preserved → behavior fully restored on revert.=== EVIDENCE ===
src/: internal — block_reference._load_catalog (5), block_reference._get_block_by_id (2), block_selector.load_catalog (2), block_selector._get_block_by_id (2), renderer._load_catalog_map (3), renderer._load_catalog_map_with_variants (1). External — fit_verifier 4 / space_allocator 5 / pipeline 1 / scripts/test_phase_q.py 1._load_catalog(not src import) → no test impact. scripts/build_block_index.py has its ownload_catalog→ out of scope.grep catalog.yaml src/.D:\ad-hoc\kei\design_agent\src\. NOT verified: concurrent-mtime equivalence (Stage 1 deferred); downstream test coverage breadth (Stage 4 pytest will surface regressions).FINAL_CONSENSUS: pending Codex review
[Codex #2] Stage 2 simulation-plan review IMP-27 K5
=== IMPLEMENTATION_UNITS ===
units:
summary: Create src/catalog.py as the sole file-read + mtime-cache owner with load_root_catalog, load_blocks, get_block_by_id, and get_catalog_mtime.
files: [src/catalog.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 45
summary: Delegate block_reference._load_catalog and block_reference._get_block_by_id to src.catalog while preserving list[dict] and no-catalog-argument contracts.
files: [src/block_reference.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 18
summary: Delegate block_selector.load_catalog and block_selector._get_block_by_id to src.catalog while preserving root-dict and catalog-injected contracts.
files: [src/block_selector.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 22
summary: Refactor renderer._load_catalog_map and _load_catalog_map_with_variants to consume the shared root catalog while keeping renderer-local projection caches.
files: [src/renderer.py]
tests: [tests/test_catalog_shared_loader.py]
estimate_lines: 35
=== PER-UNIT RATIONALE ===
u1: Covers the issue's duplicated catalog-load axis by centralizing the only shared primitive: catalog.yaml read + mtime cache. Keeps schema and path unchanged. Unit is 1 file and under 50 estimated lines.
u2: Covers block_reference list-return loader and no-argument _get_block_by_id without touching fit_verifier, space_allocator, pipeline, or internal callers. Unit is 1 file and under 50 estimated lines.
u3: Covers block_selector root-dict loader and catalog-injected _get_block_by_id while preserving existing public signatures. Unit is 1 file and under 50 estimated lines.
u4: Covers renderer projection loaders as derived maps, not new root readers, preserving id->template and id--variant->template semantics. Unit is 1 file and under 50 estimated lines.
=== OUT-OF-SCOPE ===
=== ROLLBACK STRATEGY ===
Revert the Stage 3 commit that introduces src/catalog.py and the three delegating edits; no data migration or catalog content rollback is required.
=== EVIDENCE ===
Commands run:
Files checked:
Test results:
Verified facts:
FINAL_CONSENSUS: YES
[Claude #3] Stage 3 code-edit Round #1 — IMP-27 K5
Executing unit: u1
■ unit_executed
src/catalog.pyas the sole file-read + mtime-cache owner withload_root_catalog,load_blocks,get_block_by_id,get_catalog_mtime.■ files_changed
D:\ad-hoc\kei\design_agent\src\catalog.py(76 lines)D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py(171 lines, 10 tests)■ diff_summary
src/catalog.py(new module):CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml",_catalog_cache: dict | None = None,_catalog_mtime: float = 0.0.load_root_catalog() -> dict: file-read + mtime cache. Missing file → warns and returns{"blocks": []}(matches the pre-IMP-27 behavior ofblock_selector.load_catalogandrenderer._load_catalog_map).load_blocks() -> list[dict]: returnsload_root_catalog().get("blocks", [])— the contract thatblock_reference._load_catalogwill delegate to in u2.get_block_by_id(block_id, catalog=None) -> dict | None: canonical form per Q3.catalog=Noneauto-loads (block_reference form); caller-supplied dict honored (block_selector form).get_catalog_mtime() -> float: exposes cached mtime so the renderer projection caches in u4 can key off the shared invalidation signal.tests/test_catalog_shared_loader.py(new test suite, 10 tests):tmp_pathfixture +monkeypatchofCATALOG_PATHto avoid coupling tests to the deletedtemplates/catalog.yaml(see follow-up below).get_block_by_idsignatures, unknown-id → None, single-file-read invariant on cold cache, mtime invalidation triggers reload,get_catalog_mtimesemantics, missing-file →{"blocks": []}.pytest tests/test_catalog_shared_loader.py -q→ 10 passed.■ scope-lock verification
block_reference.py,block_selector.py,renderer.py,fit_verifier.py,space_allocator.py,pipeline.py(those are u2/u3/u4).git status --shortfor this unit: 2 untracked files (src/catalog.py,tests/test_catalog_shared_loader.py). Existing tracked files unchanged.■ remaining_units
block_reference._load_catalogandblock_reference._get_block_by_idtosrc.catalog, preservinglist[dict]and no-catalog-argument contracts.block_selector.load_catalogandblock_selector._get_block_by_idtosrc.catalog, preserving root-dict and catalog-injected contracts.renderer._load_catalog_mapand_load_catalog_map_with_variantsto consume the shared root catalog while keeping renderer-local projection caches keyed offget_catalog_mtime().■ follow_up_issue_candidates
templates/catalog.yamlwas deleted in commitcc2f434("cleanup: legacy templates/blocks") on the legacy-cleanup axis. The three loaders enumerated in Stage 1 therefore operate on a permanently-absent file in current main. Two of them (block_selector.load_catalog,renderer._load_catalog_map) already handle this gracefully;block_reference._load_catalograisesFileNotFoundError. After u2, that wrapper will inherit the graceful contract — this is the strict cleanup intent of IMP-27 and stays within "behavior-preserving" since the Phase Z runtime never reaches the raising path (percc2f434commit message: "Phase Z runtime / V4 catalog 영향 0"). No follow-up issue required; calling this out for Codex Stage 4 cross-check.src/catalog_blocks.pyis a separate loader fortemplates/catalog/blocks.yaml(Phase Z block library), unrelated to the legacytemplates/catalog.yamlchain. K5 axis does not cover it. No action.=== EVIDENCE ===
D:\ad-hoc\kei\design_agent\src\catalog.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pypython -m pytest tests/test_catalog_shared_loader.py -q→10 passed in 0.06sgit status --short src/ tests/test_catalog_shared_loader.py→ only the two new??entries):src/block_reference.py,src/block_selector.py,src/renderer.py,src/fit_verifier.py,src/space_allocator.py,src/pipeline.pyload_root_catalog→dictwith"blocks"key (test_load_root_catalog_returns_root_dict)load_blocks→list[dict](test_load_blocks_returns_list_of_block_dicts)get_block_by_id(id)no-arg form (test_get_block_by_id_without_catalog_arg)get_block_by_id(id, catalog)injected form (test_get_block_by_id_with_catalog_arg_preserves_block_selector_contract)[Codex #3] Stage 3 code-edit review IMP-27 K5
Verdict: u1 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
D:\ad-hoc\kei\design_agent\src\catalog.pywithload_root_catalog,load_blocks,get_block_by_id, andget_catalog_mtime.load_root_catalog()owns the catalog file-read plus mtime cache and usesD:\ad-hoc\kei\design_agent\templates\catalog.yamlas the unified path.load_blocks()preserves thelist[dict]projection needed by the futureblock_reference._load_catalogwrapper.get_block_by_id(block_id, catalog=None)supports both no-catalog auto-load and catalog-injected lookup forms.get_catalog_mtime()exposes the cached mtime for the future renderer projection cache refactor.None, singleyaml.safe_loadon warm cache, mtime invalidation, cached mtime semantics, and missing-file fallback.Scope qualification:
src\block_reference.py,src\block_selector.py, andsrc\renderer.pystill contain their old loaders; that is expected because u2, u3, and u4 remain.git status --shortclaim. This does not invalidate u1.=== EVIDENCE ===
Commands run:
Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\catalog.py'Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'rg -n "def (_load_catalog|load_catalog|_load_catalog_map|_load_catalog_map_with_variants|_get_block_by_id)|_catalog_cache|_catalog_mtime|from src\.catalog|import catalog" src testsGet-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_reference.py' -TotalCount 140Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_selector.py' -TotalCount 100Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\renderer.py' -TotalCount 110git ls-files --error-unmatch src/catalog.py tests/test_catalog_shared_loader.pypytest tests/test_catalog_shared_loader.py -qgit diff --name-onlyTest-Path -LiteralPath 'D:\ad-hoc\kei\design_agent\templates\catalog.yaml'rg -n '^(CATALOG_PATH|def (load_root_catalog|load_blocks|get_block_by_id|get_catalog_mtime))|yaml\.safe_load|return \{"blocks": \[\]\}' src/catalog.py tests/test_catalog_shared_loader.pyFiles checked:
D:\ad-hoc\kei\design_agent\src\catalog.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pyD:\ad-hoc\kei\design_agent\src\block_reference.pyD:\ad-hoc\kei\design_agent\src\block_selector.pyD:\ad-hoc\kei\design_agent\src\renderer.pyTest results:
pytest tests/test_catalog_shared_loader.py -q=>10 passed in 0.06sVerified facts:
src/catalog.pyandtests/test_catalog_shared_loader.pyare not tracked yet, matching new-file u1 scope.git diff --name-onlyreturned no tracked modified files.templates/catalog.yamldoes not currently exist, and the new tests verify the missing-file empty catalog fallback.block_reference,block_selector, andrendererare unchanged and remain for u2-u4 delegation.Remaining units: [u2, u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Claude #4] Stage 3 code-edit Round #2 — IMP-27 K5
Executing unit: u2
■ unit_executed
block_reference._load_catalogandblock_reference._get_block_by_idtosrc.catalog, preservinglist[dict]and no-catalog-argument contracts. Drop the module-level_catalog_cachedict and the deadyamlimport.■ files_changed
D:\ad-hoc\kei\design_agent\src\block_reference.py(+7 / -20, 1 file, 27 lines net delta)D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py(+57, 4 new tests appended)■ diff_summary
src/block_reference.py:import yaml(no remaining yaml.* call site in this module).from src import catalog as _catalog_mod(single delegation entry point).TEMPLATES_DIR,Path,Anyretained (still used by Jinja env L38, raw template read L490, and ~10 type annotations).L129, now L104L116):_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}._load_catalog() -> list[dict]shrunk to 1-line wrapper:return _catalog_mod.load_blocks(). Preserves the exactlist[dict]return contract thatfit_verifier/space_allocator/pipelineimport as_load_catalog(8 lazy-import call sites total — see scope-lock evidence below)._get_block_by_id(block_id) -> dict | Noneshrunk to 1-line wrapper:return _catalog_mod.get_block_by_id(block_id). Preserves the no-catalog-argument signature consumed atblock_reference.py:324andblock_reference.py:462.카탈로그 로딩 (mtime 캐싱)→카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임)so future readers locate the delegation, not a now-empty cache layer.tests/test_catalog_shared_loader.py(4 new tests appended after the u1 block):test_block_reference_load_catalog_returns_list_of_blocks— verifies thelist[dict]contract is preserved through the wrapper (matches fit_verifier / space_allocator / pipeline expectation).test_block_reference_get_block_by_id_no_arg_signature— verifies the no-catalog-arg form still resolves an id and returnsNonefor unknown ids.test_block_reference_shares_cache_with_shared_loader— usesmonkeypatchoncatalog_mod.yaml.safe_loadto count file reads; asserts thatblock_reference._load_catalog()+block_reference._get_block_by_id(...)+catalog.load_root_catalog()together produce exactly 1 file read. This is the regression guard for "no private _catalog_cache in block_reference".test_block_reference_has_no_private_catalog_cache— explicithasattrguard against_catalog_cachere-introduction.■ verification
python -m pytest tests/test_catalog_shared_loader.py -q→ 14 passed in 0.09s (10 u1 tests + 4 u2 delegation tests).python -c "from src.block_reference import _load_catalog, _get_block_by_id"→ resolves; both callable.fit_verifier/space_allocator/pipelinepattern):from src.block_reference import _load_catalog; _load_catalog()→ returnslist(contract preserved). Note:templates/catalog.yamlis currently absent (deleted incc2f434), so the call returns[]via shared loader's missing-file fallback rather than the oldFileNotFoundError— see scope-qualified note below.■ scope-lock verification (Stage 1 contract)
_load_catalog: preserved (test_block_reference_load_catalog_returns_list_of_blocks)._get_block_by_id: preserved (test_block_reference_get_block_by_id_no_arg_signature).templates/catalog.yaml).src/block_reference.pyheader + loader section edited)._load_catalog()→ resolve to same shared loader; no signature change._get_block_by_id(block_id)→ no signature change.fit_verifier.py:131,840,985,1004,space_allocator.py:34,390,786,pipeline.py:228) all dofrom src.block_reference import _load_catalog; symbol still exists with identical signature → byte-identical at the call site.■ scope-qualified notes (not scope creep, flagged for transparency per RULE 5 factual-verification)
block_reference._load_catalograisedFileNotFoundErroron missingtemplates/catalog.yaml(no.exists()guard at the old L113, surfaced by Stage 1 problem-review). Post-u2, the shared loader returns{"blocks": []}and the wrapper returns[]. This unifies behavior withblock_selector.load_catalogandrenderer._load_catalog_mapwhich already returned empty on missing file. Stage 1 explicitly framed this as part of "file-read + mtime-cache layer 3 모듈 중복" consolidation; return contract (list[dict]) is unchanged. Sincetemplates/catalog.yamlis currently deleted (cc2f434), no active runtime path exercises the missing-file branch — all 8 external lazy importers are themselves dead-import surfaces already flagged in Stage 1.yamlimport removed from block_reference: confirmed unused after delegation viagrep -n "yaml" src/block_reference.py→ only "catalog.yaml" string in docstring/comments remain. No other module importsyamlfromblock_reference(only the loader used it).■ remaining_units
block_selector.load_catalogandblock_selector._get_block_by_idtosrc.catalog, preserving root-dict and catalog-injected contracts; drop module-level_catalog_cache/_catalog_mtime.renderer._load_catalog_mapand_load_catalog_map_with_variantsto consume the shared root catalog while keeping renderer-local projection caches keyed offget_catalog_mtime().■ follow_up_issue_candidates
templates/catalog.yamlitself is absent. All eight external lazy importers (fit_verifier4,space_allocator3,pipeline1) now resolve to alistreturning[]. Whether to (a) delete those dead imports, (b) repoint totemplates/catalog/blocks.yaml, or (c) keep the no-op guard is an axis already noted in Stage 1 unresolved-questions discussion. Out of u2 scope; flagged for follow-up issue if the project wants to remove the legacy callers entirely.unit_executed: u2
files_changed: src/block_reference.py, tests/test_catalog_shared_loader.py
remaining_units: [u3, u4]
follow_up_issue_candidates: [F-1 (carry-over from u1 — legacy block_reference._load_catalog lazy importers in fit_verifier / space_allocator / pipeline now resolve to empty list[dict])]
[Codex #4] Stage 3 code-edit review IMP-27 K5
Verdict: u2 is correct and scope-locked. Continue with the remaining implementation units.
Verified unit:
src.block_reference._load_catalog()tosrc.catalog.load_blocks(), preserving the no-argumentlist[dict]contract used by existing callers.src.block_reference._get_block_by_id(block_id)tosrc.catalog.get_block_by_id(block_id), preserving the no-catalog-argument lookup contract.src.block_referenceno longer importsyamland no longer defines its own_catalog_cache.tests/test_catalog_shared_loader.pyand pass together with the u1 shared-loader tests.Scope qualification:
src/block_selector.pyandsrc/renderer.pyare still unchanged; that is expected because u3 and u4 remain.src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pycall sites remain untouched and still call_load_catalog()with no arguments.templates/catalog.yamlcontent was changed.=== EVIDENCE ===
Commands run:
git diff -- src/block_reference.py tests/test_catalog_shared_loader.pyrg -n "import yaml|from src import catalog|_catalog_cache|def _load_catalog|def _get_block_by_id|yaml\\." src/block_reference.py tests/test_catalog_shared_loader.pyGet-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_reference.py' -TotalCount 150Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'python -m pytest tests/test_catalog_shared_loader.py -qrg -n "_load_catalog\\(|_get_block_by_id\\(" src/block_reference.py src/fit_verifier.py src/space_allocator.py src/pipeline.pygit status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yamlpython -c "from src.block_reference import _load_catalog, _get_block_by_id; import inspect; print(inspect.signature(_load_catalog)); print(inspect.signature(_get_block_by_id))"Test-Path -LiteralPath 'D:\ad-hoc\kei\design_agent\.orchestrator\drafts\27_stage_code-edit_codex_r2.md'Files checked:
D:\ad-hoc\kei\design_agent\src\block_reference.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pyD:\ad-hoc\kei\design_agent\src\fit_verifier.pyD:\ad-hoc\kei\design_agent\src\space_allocator.pyD:\ad-hoc\kei\design_agent\src\pipeline.pyTest results:
python -m pytest tests/test_catalog_shared_loader.py -q=>14 passed in 0.08sVerified facts:
src/block_reference.pyimportsfrom src import catalog as _catalog_mod.src/block_reference.pyhas noimport yaml, noyaml.*call, and no_catalog_cachedefinition.src/block_reference.pydefines_load_catalog() -> list[dict]as a wrapper around_catalog_mod.load_blocks().src/block_reference.pydefines_get_block_by_id(block_id: str) -> dict | Noneas a wrapper around_catalog_mod.get_block_by_id(block_id).() -> 'list[dict]'and(block_id: 'str') -> 'dict | None'.src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pystill use no-argument_load_catalog()call sites.M src/block_reference.py,?? src/catalog.py, and?? tests/test_catalog_shared_loader.py; no scoped changes are present forsrc/block_selector.py,src/renderer.py,src/fit_verifier.py,src/space_allocator.py,src/pipeline.py, ortemplates/catalog.yaml.Remaining units: [u3, u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Claude #5] Stage 3 code-edit Round #3 — IMP-27 K5
Executing unit: u3
■ unit_executed
block_selector.load_catalogandblock_selector._get_block_by_idtosrc.catalog, preserving the root-dict and catalog-injected contracts. Drop module-levelCATALOG_PATH,_catalog_cache,_catalog_mtime, and the now-unusedyaml/pathlib.Pathimports.■ files_changed
D:\ad-hoc\kei\design_agent\src\block_selector.py(+7 / -32 net,git diff --statconfirms)D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py(+71, 4 new tests appended after the u2 block)■ diff_summary
src/block_selector.py:from pathlib import Path(only consumer was the deletedCATALOG_PATH).import yaml(only consumer was the deletedload_catalogbody).from src import catalog as _catalog_mod(single delegation entry point — mirrors the u2 wiring inblock_reference.py:30).from typing import Anyretained (still used bytopic: dict[str, Any]inselect_block_candidates).from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDERretained.CATALOG_PATH = Path("templates/catalog.yaml")._catalog_cache: dict | None = None._catalog_mtime: float = 0.0.L83, now L51L58):load_catalog() -> dictshrunk to 1-line wrapper:return _catalog_mod.load_root_catalog(). Preserves the root-dict return contract consumed byselect_block_candidates(L83 → L86 of the new file),select_fallback_candidates(post-edit L177 region),scripts/test_phase_q.py:26(external importfrom src.block_selector import ... load_catalog), and theformat_candidates_for_promptcaller chain._get_block_by_id(block_id: str, catalog: dict) -> dict | Noneshrunk to 1-line wrapper:return _catalog_mod.get_block_by_id(block_id, catalog). Preserves the catalog-injected signature (positionalcatalogrequired by the existing definition; the canonicalsrc.catalog.get_block_by_idaccepts a positionalcatalogso the wrapper is byte-equivalent at call sites).catalog.yaml 로딩 (mtime 캐시)→catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임)so future readers locate the delegation, not a now-deleted local cache.tests/test_catalog_shared_loader.py(4 new tests appended after the u2 block, lines 237~303):test_block_selector_load_catalog_returns_root_dict— verifies the root-dict contract (isinstance(root, dict),"blocks" in root,isinstance(root["blocks"], list)) is preserved through the wrapper. This is the contract thatselect_block_candidates(catalog=None)andscripts/test_phase_q.py:26rely on.test_block_selector_get_block_by_id_catalog_injected_signature— verifies the catalog-injected form still resolves an id from a caller-supplied dict and returnsNonefor unknown ids (assertsblock_selector._get_block_by_id(block_id, catalog_dict)still accepts positional catalog).test_block_selector_shares_cache_with_shared_loader— usesmonkeypatchoncatalog_mod.yaml.safe_loadto count file reads; asserts thatblock_selector.load_catalog()+block_selector._get_block_by_id(...)+catalog.load_root_catalog()together produce exactly 1 file read. This is the regression guard for "no private_catalog_cacheinblock_selector".test_block_selector_has_no_private_catalog_cache— explicithasattrguards against re-introduction of_catalog_cache,_catalog_mtime, orCATALOG_PATHon theblock_selectormodule.■ scope-lock verification
src/block_selector.py+ test file. No edits tosrc/renderer.py(u4) or any caller-side file (src/kei_client.py:454,scripts/test_phase_q.py:26).git diff --name-only -- src/ tests/lists onlysrc/block_reference.py(u2 carry-over from Round #2; unchanged this round) andsrc/block_selector.py.tests/test_catalog_shared_loader.pyandsrc/catalog.pyremain untracked (u1 carry-over). No spurious file in scope.src.catalog.CATALOG_PATHis the single source of truth and is unchanged.python -c "from src.block_selector import load_catalog, _get_block_by_id; import inspect; print(inspect.signature(load_catalog), inspect.signature(_get_block_by_id))"→() -> 'dict'and(block_id: 'str', catalog: 'dict') -> 'dict | None'— byte-identical to the pre-IMP-27 signatures.rg -n "block_selector\\.(CATALOG_PATH|_catalog_cache|_catalog_mtime)|from src\\.block_selector import.*(CATALOG_PATH|_catalog_cache|_catalog_mtime)" --include="*.py" .→ only the new u3 guard test lines (303). No other code in the repo depends on the removed symbols, so no caller-side update is required.■ verification
python -m pytest tests/test_catalog_shared_loader.py -q→ 18 passed in 0.26s (10 u1 tests + 4 u2 tests + 4 u3 tests).python -c "from src.block_selector import load_catalog, _get_block_by_id, select_block_candidates, format_candidates_for_prompt"→ resolves; full public surface importable.git diff --stat -- src/block_selector.py→1 file changed, 7 insertions(+), 32 deletions(-). Net delta consistent with "delegate + drop local cache" — no scope creep.git status --short -- src/block_selector.py tests/test_catalog_shared_loader.py src/catalog.py→M src/block_selector.py,?? tests/test_catalog_shared_loader.py,?? src/catalog.py. Consistent with cumulative state across u1+u2+u3.■ remaining_units
renderer._load_catalog_mapand_load_catalog_map_with_variantsto consume the shared root catalog while keeping renderer-local projection caches keyed offsrc.catalog.get_catalog_mtime().■ follow_up_issue_candidates
templates/catalog.yamlwas deleted incc2f434during a legacy block library cleanup; the loader contract still exists for any remaining Phase Q call sites but no production catalog file is present) remains informational — outside IMP-27 K5 scope; surface separately if any Phase Q runtime path actually needs a populated catalog.scope-lock retained. Awaiting Codex u3 review before proceeding to u4.
[Codex #5] Stage 3 code-edit review IMP-27 K5
Verdict: u3 is correct and scope-locked. Continue with the remaining implementation unit.
Verified unit:
src.block_selector.load_catalog()tosrc.catalog.load_root_catalog(), preserving the no-argument root-dict contract used internally and byscripts/test_phase_q.py.src.block_selector._get_block_by_id(block_id, catalog)tosrc.catalog.get_block_by_id(block_id, catalog), preserving the catalog-injected signature.src.block_selectorno longer importsyamlorpathlib.Path, and no longer definesCATALOG_PATH,_catalog_cache, or_catalog_mtime.tests/test_catalog_shared_loader.pyand pass together with the existing u1 and u2 tests.Scope qualification:
src/renderer.pyis still unchanged; that is expected because u4 remains.src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pycall sites remain untouched.templates/catalog.yamlcontent was changed.=== EVIDENCE ===
Commands run:
git diff -- src/block_selector.py tests/test_catalog_shared_loader.pyrg -n "from pathlib import Path|import yaml|from src import catalog|CATALOG_PATH|_catalog_cache|_catalog_mtime|def load_catalog|def _get_block_by_id|yaml\." src/block_selector.py tests/test_catalog_shared_loader.pyGet-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\block_selector.py' -TotalCount 120Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'python -m pytest tests/test_catalog_shared_loader.py -qrg -n "load_catalog\(|_get_block_by_id\(" src/block_selector.py scripts tests src | sortpython -c "from src.block_selector import load_catalog, _get_block_by_id; import inspect; print(inspect.signature(load_catalog)); print(inspect.signature(_get_block_by_id))"git status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yamlrg -n "from src\.block_selector import|import .*block_selector|select_block_candidates|select_fallback_candidates|load_catalog" scripts/test_phase_q.py src/block_selector.pyrg -n "from pathlib import Path|import yaml|CATALOG_PATH|_catalog_cache|_catalog_mtime|yaml\." src/block_selector.pygit diff -- src/renderer.pyFiles checked:
D:\ad-hoc\kei\design_agent\src\block_selector.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pyD:\ad-hoc\kei\design_agent\scripts\test_phase_q.pyD:\ad-hoc\kei\design_agent\src\renderer.pyTest results:
python -m pytest tests/test_catalog_shared_loader.py -q=>18 passed in 0.08sVerified facts:
src/block_selector.pyimportsfrom src import catalog as _catalog_mod.load_catalog() -> dictreturns_catalog_mod.load_root_catalog()._get_block_by_id(block_id: str, catalog: dict) -> dict | Nonereturns_catalog_mod.get_block_by_id(block_id, catalog).src/block_selector.pyhas noyaml,Path,CATALOG_PATH,_catalog_cache, or_catalog_mtimematches.scripts/test_phase_q.pystill imports and callsload_catalog()with no arguments.src/renderer.pyhas no diff in this unit and remains for u4.Remaining units: [u4]
rewind_target: continue_same
FINAL_CONSENSUS: NO
[Claude #6] Stage 3 code-edit Round #4 — IMP-27 K5
Executing unit: u4
■ unit_executed
renderer._load_catalog_mapand_load_catalog_map_with_variantsto consume the shared root catalog viasrc.catalog.load_blocks(), keep renderer-local projection caches, and key projection invalidation offsrc.catalog.get_catalog_mtime(). Droprenderer.CATALOG_PATH, theyamlimport, and the legacy single_CATALOG_MTIMEmodule global; replace with per-projection keys_CATALOG_MAP_MTIME/_CATALOG_VARIANT_MAP_MTIME.■ files_changed
D:\ad-hoc\kei\design_agent\src\renderer.py(+39 / -49, net -10 lines pergit diff --stat)D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py(+109, fixture extended + 5 new u4 tests appended; file remains untracked from u1)■ diff_summary
src/renderer.py:import yaml(no remainingyaml.*call site in this module).from src import catalog as _catalog_mod(single delegation entry point — mirrors u2 wiring atblock_reference.py:30and u3 wiring atblock_selector.py).from collections import OrderedDict,from pathlib import Path,from typing import Any,from jinja2 import Environment, FileSystemLoader, andloggerretained (still used byTEMPLATES_DIRL21,STATIC_DIRL22,OrderedDictgroup grouping later in the file, Jinja env, and type hints).L30, L65L66 → now L20~L26):CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"(path lives insrc.catalog.CATALOG_PATHonly)._CATALOG_MTIME: float = 0.0(single legacy key — replaced by per-projection keys so the two projections invalidate independently and correctly underget_catalog_mtime()keying)._CATALOG_MAP_MTIME: float = 0.0and_CATALOG_VARIANT_MAP_MTIME: float = 0.0(renderer-local projection cache keys; values come from_catalog_mod.get_catalog_mtime())._CATALOG_MAP: dict[str, str] | None = Noneretained (projection cache)._CATALOG_VARIANT_MAP: dict[str, str] | None = Noneretained (projection cache); moved up next to its mtime key (formerly L65L66, now L25L26) so both projection caches sit together.catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)→id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)._load_catalog_map() -> dict[str, str](formerly L32L62, now L29L55):_catalog_mod.load_blocks()for raw data and_catalog_mod.get_catalog_mtime()for the cache key; projection map rebuild iterates the returnedlist[dict]and writesid → template.open(CATALOG_PATH, ...),yaml.safe_load, the missing-file warning, the exception swallowing) fully removed — those concerns now live insrc.catalog.load_root_catalog.dict[str, str]keyed by block id. Logging linecatalog.yaml 로드: {N}개 블록 매핑preserved (callers / log monitors unaffected)._load_catalog_map_with_variants() -> dict[str, str](formerly L69L97, now L58L80):_catalog_mod.load_blocks()directly. Removed the redundant_load_catalog_map()pre-call (the variant cache now keys offget_catalog_mtime()independently, so the side-effectful "force the other cache to refresh" trick is no longer needed).open(CATALOG_PATH, ...)+yaml.safe_loadblock — single shared file read enforced bysrc.catalog.dict[str, str]keyed by"block_id--variant_id", excludingdefaultvariants. Verified bytest_renderer_load_catalog_map_with_variants_returns_compound_key_dictbelow.tests/test_catalog_shared_loader.py:fixture-block-bnow carriesvariantswith adefaultand acompactentry, enabling the variant projection test to exercise thedefault-exclusion rule (preserves pre-IMP-27 behavior inrenderer._load_catalog_map_with_variants)._reset_renderer_projection_cache()helper added (lines 47~53) and called from thefixture_catalog_pathfixture (line 65) so each test starts with both renderer-local projection caches cleared. Without this, the module-level state would leak across tests.test_renderer_load_catalog_map_returns_id_to_template_dict— verifiesid → templateprojection contract (matches existing callers_resolve_template_pathat L135 of the new renderer).test_renderer_load_catalog_map_with_variants_returns_compound_key_dict— verifies"id--variant" → templateprojection, with explicit assertion thatdefaultvariants are excluded.test_renderer_shares_cache_with_shared_loader— monkeypatchessrc.catalog.yaml.safe_loadto count file reads; asserts thatrenderer._load_catalog_map()+renderer._load_catalog_map_with_variants()+catalog.load_root_catalog()together produce exactly 1 file read. This is the regression guard for "renderer must not reopen the catalog file".test_renderer_projection_invalidates_when_shared_mtime_changes— rewrites the fixture file, bumps mtime, resetssrc.catalog._catalog_cache, and asserts thatrenderer._load_catalog_map()returns the new contents. Locks the contract "renderer projection cache keyed offsrc.catalog.get_catalog_mtime()".test_renderer_has_no_private_catalog_path_or_yaml— explicithasattrguards against re-introduction ofCATALOG_PATH,yaml, and the legacy_CATALOG_MTIMEsingle-key.■ verification (scope = u4 only)
python -m pytest tests/test_catalog_shared_loader.py -q→ 23 passed in 0.14s (10 u1 + 4 u2 + 4 u3 + 5 u4).python -c "from src.renderer import _load_catalog_map, _load_catalog_map_with_variants; m=_load_catalog_map(); v=_load_catalog_map_with_variants()"→ resolves; both callable. Logged the expectedcatalog.yaml 미발견warning (current repo state:templates/catalog.yamldeleted incc2f434, same precondition as u1 fixture-mode tests).python -c "import src.renderer as r; for n in ('CATALOG_PATH','yaml','_CATALOG_MTIME','_CATALOG_MAP_MTIME','_CATALOG_VARIANT_MAP_MTIME'): print(n, hasattr(r,n))"→CATALOG_PATH False,yaml False,_CATALOG_MTIME False,_CATALOG_MAP_MTIME True,_CATALOG_VARIANT_MAP_MTIME True. Confirms scope-lock: legacy state gone, new per-projection mtime keys present.git diff --stat -- src/renderer.py→1 file changed, 39 insertions(+), 49 deletions(-).git status --shortfor this unit:M src/renderer.py,?? tests/test_catalog_shared_loader.py(untracked since u1). No other tracked files modified.■ scope-lock verification
src/catalog.py,src/block_reference.py,src/block_selector.py,src/fit_verifier.py,src/space_allocator.py,src/pipeline.py._load_catalog_map(L77 → now L126 region inside_resolve_template_path) and_load_catalog_map_with_variants(L126 region) preserved as no-arg calls returningdict[str, str]— verified by re-grep_load_catalog_map\(insrc/renderer.py→ unchanged signatures at the call site.src.catalogintroduced in u1 is loader-only and pulls no Phase R' rendering symbols.■ remaining_units
■ follow_up_issue_candidates
templates/catalog.yamlwas deleted incc2f434("legacy block library cleanup"). All four loaders (src.catalog+ the three wrappers) gracefully return empty ({"blocks": []}/[]/{}); production behavior is unchanged because no live caller currently requires populated content from that path. Not in IMP-27 scope — IMP-04 (Phase R' / Phase Z catalog re-introduction axis) is the natural home for any re-population decision. Already filed as F-1 in r1.src/renderer.pystill has liveOrderedDict/Path/Any/ Jinja imports, but the legacy_CATALOG_MTIMErename to per-projection keys reveals that the renderer-local projection caches duplicate a pattern that could itself be hoisted intosrc.catalog(e.g. a genericprojection_cache(name, builder)helper). Not in IMP-27 scope — IMP-27 K5 axis is loader unification only; projection-cache helper extraction is a candidate for a follow-up cleanup axis ifsrc.rendererand any future projection consumer end up sharing >2 such caches. Filing for awareness only; do not open a new issue unless triggered.[Codex #6] Stage 3 code-edit review IMP-27 K5
Verdict: u4 is correct and scope-locked. All planned Stage 3 implementation units are now complete.
Verified unit:
src.renderer._load_catalog_map()andsrc.renderer._load_catalog_map_with_variants()to the shared catalog loader throughsrc.catalog.load_blocks().dict[id, template_path]anddict["id--variant", template_path].src.catalog.get_catalog_mtime().src.rendererno longer importsyaml, no longer definesCATALOG_PATH, and no longer uses the legacy single_CATALOG_MTIMEglobal.tests/test_catalog_shared_loader.pyand pass together with the u1, u2, and u3 tests.Scope qualification:
src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pycall sites remain untouched.templates/catalog.yamlcontent was changed.=== EVIDENCE ===
Commands run:
git diff -- src/renderer.py tests/test_catalog_shared_loader.pyrg -n "import yaml|from src import catalog|CATALOG_PATH|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|def _load_catalog_map|def _load_catalog_map_with_variants|yaml\.|load_blocks\(|get_catalog_mtime\(" src/renderer.py tests/test_catalog_shared_loader.pyGet-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\renderer.py' -TotalCount 180Get-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.py'python -m pytest tests/test_catalog_shared_loader.py -qpython -c "from src.renderer import _load_catalog_map, _load_catalog_map_with_variants; import inspect; print(inspect.signature(_load_catalog_map)); print(inspect.signature(_load_catalog_map_with_variants))"rg -n "_load_catalog_map\(|_load_catalog_map_with_variants\(" src/renderer.py src tests scriptsGet-Content -LiteralPath 'D:\ad-hoc\kei\design_agent\src\catalog.py'git status --short -- src/catalog.py tests/test_catalog_shared_loader.py src/block_reference.py src/block_selector.py src/renderer.py src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yamlsrc.catalog.CATALOG_PATHto a temp catalog, callrenderer._load_catalog_map(), rewrite the catalog with a newer mtime, callrenderer._load_catalog_map()again, and confirm the projection changes from{'a': 'blocks/a.html'}to{'b': 'blocks/b.html'}Files checked:
D:\ad-hoc\kei\design_agent\src\renderer.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pyD:\ad-hoc\kei\design_agent\src\catalog.pyD:\ad-hoc\kei\design_agent\src\fit_verifier.pyD:\ad-hoc\kei\design_agent\src\space_allocator.pyD:\ad-hoc\kei\design_agent\src\pipeline.pyTest results:
python -m pytest tests/test_catalog_shared_loader.py -q=>23 passed in 0.11sVerified facts:
src.rendererimportsfrom src import catalog as _catalog_mod._load_catalog_map() -> dict[str, str]calls_catalog_mod.load_blocks()and_catalog_mod.get_catalog_mtime()._load_catalog_map_with_variants() -> dict[str, str]calls_catalog_mod.load_blocks()and_catalog_mod.get_catalog_mtime().src.renderer.src.rendererhas noimport yaml, noyaml.*call, noCATALOG_PATH, and no_CATALOG_MTIME.git status --short -- ...reports only the expected IMP-27 files changed or untracked:src/catalog.py,tests/test_catalog_shared_loader.py,src/block_reference.py,src/block_selector.py, andsrc/renderer.py.Remaining units: none
FINAL_CONSENSUS: YES
[Claude #7] Stage 4 test-verify — IMP-27 K5 catalog cleanup Round #1
검증 결과 요약 (Korean)
src/catalog.py) + 3 wrapper module + 23 unit case + 365 broader regression case 모두 GREEN.src/catalog.py(NEW, 77 lines),src/block_reference.py(-15 / +6, net -9),src/block_selector.py(-30 / +9, net -21),src/renderer.py(-49 / +39, net -10),tests/test_catalog_shared_loader.py(NEW, 422 lines).src/catalog.py단독 소유 (block_search.py는 IMP-27 Stage 1 scope 외;phase_z2_*의 yaml import 는 다른 catalog 파일들 = frame_contracts / layouts / region_layouts / display_strategies, IMP-27 templates/catalog.yaml 과 별 데이터).=== EVIDENCE ===
1. Targeted test run
23 / 23 PASS. 분포:
id → templateprojection /id--variant → templateprojection / shared cache / projection invalidation on shared mtime change / private CATALOG_PATH+yaml+legacy _CATALOG_MTIME absence.2. Broader regression check (commit scope)
365 / 365 PASS (test 수집 시점).
tests/matching/**는 Stage 1 scope 외 (별 axis — V1~V4 매칭 시스템) — 의식적 제외.3. Signature preservation check
Stage 1 exit report의 6 wrapper contract 와 byte-identical.
4. Call-site enumeration (caller import 보존)
src/fit_verifier.pyL131, L840, L985, L1004)src/space_allocator.pyL34, L390, L786) + 추가 module-level grep 5src/pipeline.pyL228)(line 번호는 Stage 1 snapshot 시점과 동일 — caller 파일 unchanged.)
5. Hardcoding / RULE 7 check
templates/catalog.yaml경로 + mtime cache state grep:src/catalog.pysrc/block_reference.pysrc/block_selector.pyCATALOG_PATH)src/renderer.pyCATALOG_PATH)import yaml)_CATALOG_MTIME→ projection-local_CATALOG_MAP_MTIME/_CATALOG_VARIANT_MAP_MTIME)src/block_search.pysrc/phase_z2_composition.py/_mapper.py/_pipeline.pyRULE 7 (no hardcoding) PASS — 경로/cache 가 process-level 단일 owner 로 통합되었고 wrapper 는 1-line delegation.
6. Scope-lock guardrail (RULE 4)
src/catalog.py(NEW) +src/block_reference.py+src/block_selector.py+src/renderer.py+tests/test_catalog_shared_loader.py(NEW). Stage 2 plan 의 5 path 와 정확 일치.7. Out-of-scope follow-up 후보 (Stage 3 exit report에 명시 — 본 verify에서 작업 X)
src/fit_verifier.py/src/space_allocator.py/src/pipeline.py는 본 issue Stage 1 의 scope_lock 에서 명시적 제외 — caller만, 자체 로딩 코드 없음.tests/test_catalog_shared_loader.pytracking 여부 = Stage 5 commit boundary 질문 (verify 단계 결정 사항 아님).판정
PASS — Stage 4 verify Round #1 통과.
다음 단계: Codex #7 재검증 → 통과 시 Stage 5 (commit + push).
[Codex #7] Stage 4 test-verify IMP-27 K5 Round #1
Decision: PASS; commit OK signal.
Scope-qualified verification completed for the IMP-27 commit scope. I found no blocking discrepancy in the implementation, signatures, call-site preservation, catalog path ownership, or tests. One non-blocking bookkeeping correction: the previous raw line-count claim for the two new files does not reproduce in this workspace (
src/catalog.py= 76 lines,tests/test_catalog_shared_loader.py= 421 lines byGet-Content), so I am not using that count as a pass criterion.=== EVIDENCE ===
Commands run:
git status --shortgit diff -- src/catalog.py src/block_reference.py src/block_selector.py src/renderer.py tests/test_catalog_shared_loader.pyrg -n "def _load_catalog|def load_catalog|def _load_catalog_map|def _load_catalog_map_with_variants|def _get_block_by_id|CATALOG_PATH|import yaml|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|load_blocks\(|get_catalog_mtime\(" src tests/test_catalog_shared_loader.pyGet-Content -LiteralPath 'src\catalog.py'rg -n "from src\.block_reference import _load_catalog|_load_catalog\(|load_catalog\(|_get_block_by_id\(|_load_catalog_map\(|_load_catalog_map_with_variants\(" src\fit_verifier.py src\space_allocator.py src\pipeline.py src\block_reference.py src\block_selector.py src\renderer.pygit diff -- src/fit_verifier.py src/space_allocator.py src/pipeline.pypython -m pytest tests/test_catalog_shared_loader.py -vpython -m pytest tests/ -q --ignore=tests/matchingpython -signature inspection for the six public/private wrapper functionsgit diff --stat -- src/block_reference.py src/block_selector.py src/renderer.pyGet-Contentline counts forsrc\catalog.pyandtests\test_catalog_shared_loader.pygit diff -- templates/catalog.yaml src/fit_verifier.py src/space_allocator.py src/pipeline.pyFiles checked:
D:\ad-hoc\kei\design_agent\src\catalog.pyD:\ad-hoc\kei\design_agent\src\block_reference.pyD:\ad-hoc\kei\design_agent\src\block_selector.pyD:\ad-hoc\kei\design_agent\src\renderer.pyD:\ad-hoc\kei\design_agent\tests\test_catalog_shared_loader.pyD:\ad-hoc\kei\design_agent\src\fit_verifier.pyD:\ad-hoc\kei\design_agent\src\space_allocator.pyD:\ad-hoc\kei\design_agent\src\pipeline.pyD:\ad-hoc\kei\design_agent\templates\catalog.yamlTest results:
python -m pytest tests/test_catalog_shared_loader.py -v: 23 passed in 0.12s.python -m pytest tests/ -q --ignore=tests/matching: 365 passed in 37.80s.Verified facts:
src.catalogis the sharedtemplates/catalog.yamlfile-read + mtime-cache owner for the IMP-27 path, withload_root_catalog(),load_blocks(),get_block_by_id(), andget_catalog_mtime().src.block_reference._load_catalog()delegates tosrc.catalog.load_blocks()and preserves() -> list[dict].src.block_reference._get_block_by_id()delegates tosrc.catalog.get_block_by_id(block_id)and preserves(block_id: str) -> dict | None.src.block_selector.load_catalog()delegates tosrc.catalog.load_root_catalog()and preserves() -> dict.src.block_selector._get_block_by_id()delegates tosrc.catalog.get_block_by_id(block_id, catalog)and preserves(block_id: str, catalog: dict) -> dict | None.src.renderer._load_catalog_map()and_load_catalog_map_with_variants()consumesrc.catalog.load_blocks()and keep renderer-local projection caches keyed bysrc.catalog.get_catalog_mtime().src.rendererno longer ownsCATALOG_PATH,import yaml, or legacy_CATALOG_MTIME; it now owns_CATALOG_MAP_MTIMEand_CATALOG_VARIANT_MAP_MTIMEprojection keys.git diff -- templates/catalog.yaml src/fit_verifier.py src/space_allocator.py src/pipeline.pyis empty, so catalog data and the external call-site modules remain unchanged._load_catalog()callers infit_verifier.py,space_allocator.py,pipeline.py, internalblock_reference.py, internalblock_selector.py, and renderer projection callers; signatures are unchanged.block_search.pyandphase_z2_*catalog references found by grep are outside this IMP-27 binding scope and point to separate catalog concerns.FINAL_CONSENSUS: YES
[Claude #8] Stage 5 commit-push — IMP-27 K5 catalog cleanup Round #1
■ Commit
909bf75edc633111b325175a2d3e40639def62berefactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup2896bb6(docs(#26): IMP-26 J3 status pending->deferred + dual-precondition trigger)main■ Scope-locked files (5 — exactly per Stage 4 binding contract)
src/catalog.py(+76)src/block_reference.py(−21 / +6, net −15 —git diff --statshows 27 lines touched)src/block_selector.py(−30 / +9, net −21 — 39 lines touched)src/renderer.py(−49 / +39, net −10 — 88 lines touched)tests/test_catalog_shared_loader.py(+421)git diff --staged --stattotals: 5 files / +550 / −101.■ Push
git push slide2 main→2896bb6..909bf75 main -> main(gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2.git)git push origin main→2896bb6..909bf75 main -> main(github.com:keimin86/design_agent.git)git ls-remote slide2 refs/heads/main→909bf75… refs/heads/main✅git ls-remote origin refs/heads/main→909bf75… refs/heads/main✅■ Out-of-scope files NOT staged (confirmed)
src/fit_verifier.py,src/space_allocator.py,src/pipeline.py,templates/catalog.yaml— untouched (post-commitgit statusshows noMfor these).tests/matching/**,samples/**,.orchestrator/**,chromedriver/**,templates/phase_z2/**,scripts/**, root-level orchestrator script — left untracked (carried over from prior working tree).■ Verification (pre-commit, carried over from Stage 4)
pytest tests/test_catalog_shared_loader.py -v→ 23/23 PASS in 0.13s.pytest tests/ -q --ignore=tests/matching→ 365/365 PASS in 38.10s.■ Guardrails honored
git add -A/..git diffempty for those files).reference_design_agent_remotes: pushed to bothslide2(Gitea mirror = naming mismatchC.E.L_Slide_test2) andorigin(GitHub).gitearemote (=_Geulbeot, different project) intentionally NOT pushed.■ Stage 5 status
PASS. Commit
909bf75on both remotes; working tree returns to pre-commit untracked-only state (no scope file dirty).■ Next stage
Stage 6 final-close eligible.
rewind_target: n/a (no failure)
=== EVIDENCE ===
git log -1 --format="%H %s"→909bf75edc633111b325175a2d3e40639def62be refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanupgit diff --staged --stat(pre-commit) → 5 files / +550 / −101 / create modes forsrc/catalog.py+tests/test_catalog_shared_loader.py.git push slide2 main→2896bb6..909bf75 main -> maingit push origin main→2896bb6..909bf75 main -> maingit ls-remote slide2 refs/heads/main→909bf75edc633111b325175a2d3e40639def62begit ls-remote origin refs/heads/main→909bf75edc633111b325175a2d3e40639def62begit status --short→ noM/Alines for scope files; untracked-only entries remain (out-of-scope).git remote -v→ slide2 =https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2.git; origin =git@github.com:keimin86/design_agent.git.FINAL_CONSENSUS: YES
[Codex #8] Stage 5 commit-push verification IMP-27 K5 Round #1
Stage 5 verification PASS.
Commit verified:
909bf75edc2896bb691cRemote verification:
909bf75edc909bf75edcScope verification:
Tests:
=== EVIDENCE ===
Commands run:
Files checked:
Commit SHA:
909bf75edcVerified facts:
909bf75edcon branch main.2896bb691c.909bf75edc.909bf75edc.FINAL_CONSENSUS: YES
[Claude #9] Stage 6 final-close — IMP-27 K5 (catalog loader + _get_block_by_id cleanup) Round #1
Issue body re-read (binding goals)
_get_block_by_id중복 제거 (3 module — block_reference / block_selector / renderer).Origin verification
909bf75909bf75edc633111b325175a2d3e40639def62be2896bb6(IMP-26 J3)2896bb691c421bbae9bfa426178c92496d10dabcrefactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanupkyeongmin/2026-05-20 19:31:26 +0900origin(GitHub)refs/heads/main909bf75…909bf75edc633111b325175a2d3e40639def62beslide2(Gitea mirror)refs/heads/main909bf75…909bf75edc633111b325175a2d3e40639def62be2896bb6..909bf75Goal vs result
909bf75)src/catalog.py(NEW, +76) sole file-read + mtime-cache owner;block_reference._load_catalog,block_selector.load_catalog,renderer._load_catalog_map[_with_variants]모두 위임_get_block_by_id중복 제거src/catalog.get_block_by_id(block_id, catalog=None);block_reference._get_block_by_id(no-arg) +block_selector._get_block_by_id(catalog-injected) → 1-line wrappersgit diff 909bf75 -- templates/catalog.yaml= emptygit diff 909bf75 -- src/fit_verifier.py src/space_allocator.py src/pipeline.py= emptyshortstat:
5 files changed, 550 insertions(+), 101 deletions(-)— matches Stage 5 binding contract.Review-loop checklist (issue body)
909bf75(Stage 3 + Stage 5)Follow-up links (out-of-scope, deferred)
yaml.safe_load(CATALOG_PATH)style reads:src/fit_verifier.py(4 import sites — L131, L840, L985, L1004)src/space_allocator.py(5 import sites — L34, L390, L713, L786, L825)src/pipeline.py:228src/block_search.pyyamlimport — separate axis (block search index, not catalog loader).phase_z2_*catalog imports — separate catalog data domain (frame_contracts / layouts / region_layouts / display_strategies), nottemplates/catalog.yaml.tests/test_catalog_shared_loader.pytracking — already committed at909bf75(no decision pending).These do not block IMP-27 K5 closure — issue scope was explicitly "3 module" (block_reference + block_selector + renderer). The above are candidates for a successor cleanup issue.
Labels / milestone
Close decision
READY TO CLOSE (issue close left to user per orchestrator convention).
Rationale:
_get_block_by_iddedup + catalog.yaml untouched + return contracts preserved + caller signatures byte-identical).909bf75present on both remotes (GitHuborigin+ Giteaslide2) atrefs/heads/main.=== EVIDENCE ===
commands:
git log -1 --format='%H%n%P%n%s%n%an%n%ai' 909bf75→909bf75edc633111b325175a2d3e40639def62be/ parent2896bb691c421bbae9bfa426178c92496d10dabc/ titlerefactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup/kyeongmin/2026-05-20 19:31:26 +0900.git ls-remote origin refs/heads/main→909bf75edc633111b325175a2d3e40639def62be refs/heads/main.git ls-remote slide2 refs/heads/main→909bf75edc633111b325175a2d3e40639def62be refs/heads/main.git show --stat --format= 909bf75→ 5 files / +550 −101 (catalog.py +76 / block_reference.py 27 / block_selector.py 39 / renderer.py 88 / test_catalog_shared_loader.py +421).git diff 909bf75 -- src/fit_verifier.py src/space_allocator.py src/pipeline.py templates/catalog.yaml→ empty (out-of-scope guardrail).git log --oneline 2896bb6..909bf75→ exactly 1 commit909bf75.upstream docs:
docs/architecture/PHASE-Q-INSIGHT-TO-22STEP-MAP.md:151— §5 K5 cleanup axis.docs/architecture/PHASE-Q-AUDIT.md:1087— §2.10 K5 row.binding contracts honored:
909bf75.[Codex #9] Stage 6 final-close verify IMP-27 K5 Round #1
Verdict: PASS. Close OK signal.
I independently verified the Stage 5 commit/push state and the Stage 6 closure criteria from the local repository and remotes. The implemented result matches the issue scope: shared catalog file-read + mtime-cache ownership is centralized in
src/catalog.py, the three target modules delegate to it,_get_block_by_idis canonicalized throughsrc.catalog.get_block_by_id, and the public wrapper signatures/return contracts remain preserved.Scope guard also holds:
templates/catalog.yaml,src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pyhave no diff in the IMP-27 commit range. Bothoriginandslide2pointrefs/heads/mainat909bf75edc633111b325175a2d3e40639def62be, and the parent-to-HEAD range contains exactly one commit.=== EVIDENCE ===
Commands run:
git log -1 --format='%H%n%P%n%s%n%an%n%ai'git show --shortstat --format= HEADgit show --name-status --format= HEADgit rev-list --count 2896bb691c421bbae9bfa426178c92496d10dabc..HEADgit ls-remote origin refs/heads/maingit ls-remote slide2 refs/heads/mainrg -n "def _load_catalog|def _get_block_by_id|from src import catalog|import yaml|CATALOG_PATH|_catalog_cache|_catalog_mtime" src\block_reference.py src\block_selector.pyrg -n "def _load_catalog_map|def _load_catalog_map_with_variants|from src import catalog|import yaml|CATALOG_PATH|_CATALOG_MTIME|_CATALOG_MAP_MTIME|_CATALOG_VARIANT_MAP_MTIME|load_blocks\(|get_catalog_mtime\(" src\renderer.pygit diff HEAD -- src\fit_verifier.py src\space_allocator.py src\pipeline.py templates\catalog.yamlgit diff HEAD^ HEAD -- templates\catalog.yaml src\fit_verifier.py src\space_allocator.py src\pipeline.pypython -c "import inspect; import src.block_reference as br; import src.block_selector as bs; import src.renderer as r; ..."python -m pytest tests/test_catalog_shared_loader.py -qFiles checked:
src/catalog.pysrc/block_reference.pysrc/block_selector.pysrc/renderer.pytests/test_catalog_shared_loader.pytemplates/catalog.yamlsrc/fit_verifier.pysrc/space_allocator.pysrc/pipeline.pyCommit SHA:
909bf75edc633111b325175a2d3e40639def62beVerified facts:
909bf75edc633111b325175a2d3e40639def62bewith parent2896bb691c421bbae9bfa426178c92496d10dabc.refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup.kyeongmin,2026-05-20 19:31:26 +0900.git show --name-statuslists exactlysrc/catalog.pyA,tests/test_catalog_shared_loader.pyA, and modifications tosrc/block_reference.py,src/block_selector.py,src/renderer.py.5 files changed, 550 insertions(+), 101 deletions(-).2896bb691c421bbae9bfa426178c92496d10dabc..HEADcontains exactly 1 commit.origin refs/heads/mainandslide2 refs/heads/mainboth resolve to909bf75edc633111b325175a2d3e40639def62be.src/catalog.pydefines the sharedCATALOG_PATH,yaml.safe_load,_catalog_cache,_catalog_mtime,load_root_catalog,load_blocks,get_block_by_id, andget_catalog_mtimeownership.src/block_reference.pydelegates_load_catalog()to_catalog_mod.load_blocks()and_get_block_by_id(block_id)to_catalog_mod.get_block_by_id(block_id).src/block_selector.pydelegatesload_catalog()to_catalog_mod.load_root_catalog()and_get_block_by_id(block_id, catalog)to_catalog_mod.get_block_by_id(block_id, catalog).src/renderer.pydelegates projection inputs to_catalog_mod.load_blocks()and projection invalidation to_catalog_mod.get_catalog_mtime()while keeping renderer-local projection caches.block_reference._load_catalog(),block_reference._get_block_by_id(block_id),block_selector.load_catalog(),block_selector._get_block_by_id(block_id, catalog),renderer._load_catalog_map(),renderer._load_catalog_map_with_variants().templates/catalog.yaml,src/fit_verifier.py,src/space_allocator.py, andsrc/pipeline.pyare unchanged in the IMP-27 commit range.23 passed in 0.11s.365/365regression result.FINAL_CONSENSUS: YES