Compare commits
18
Commits
@@ -186,6 +186,12 @@ export default function SlideCanvas({
|
||||
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
||||
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
|
||||
|
||||
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
|
||||
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
|
||||
const embeddedSrc = finalHtmlUrl
|
||||
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
|
||||
: undefined;
|
||||
|
||||
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
|
||||
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
|
||||
const W_SCALED = SLIDE_W * scale;
|
||||
@@ -283,37 +289,21 @@ export default function SlideCanvas({
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={finalHtmlUrl}
|
||||
src={embeddedSrc}
|
||||
title="Phase Z 렌더 결과"
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
|
||||
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
|
||||
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
|
||||
// backend auto-mode detection script 를 trigger 해서 html.embedded
|
||||
// class 를 붙이고 standalone-only body 규칙을 reset.
|
||||
try {
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
if (!doc) return;
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = `
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 0 !important;
|
||||
height: 720px !important;
|
||||
width: 1280px !important;
|
||||
background: transparent !important;
|
||||
display: block !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.slide {
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
|
||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||
@@ -612,6 +602,28 @@ export default function SlideCanvas({
|
||||
: null;
|
||||
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
||||
|
||||
// IMP-11 u4: active frame lookup — distinct axis from preview.
|
||||
// preview is shown only when override differs from default; active is
|
||||
// always defined as override-if-present-else-default. Used by u5 to
|
||||
// compare the active frame's catalog min_height_px against zone height.
|
||||
const activeFrameId = overrideFrameId ?? defaultFrameId;
|
||||
const activeCandidate = activeFrameId
|
||||
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
|
||||
: undefined;
|
||||
|
||||
// IMP-11 u5: catalog min_height_px violation hint. height is already
|
||||
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
|
||||
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
|
||||
// not re-multiplied (double-apply would shrink the comparison value).
|
||||
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
|
||||
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
|
||||
const minHeightPx = activeCandidate?.minHeightPx ?? null;
|
||||
const belowMinHeight =
|
||||
isPendingLayout &&
|
||||
minHeightPx != null &&
|
||||
zoneHeightPx != null &&
|
||||
zoneHeightPx < minHeightPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={zone.id}
|
||||
@@ -695,6 +707,18 @@ export default function SlideCanvas({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
|
||||
is below the active frame's catalog min_height_px. Visual
|
||||
hint only, no clamp/resize behavior change. */}
|
||||
{belowMinHeight && minHeightPx != null && (
|
||||
<>
|
||||
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
|
||||
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
|
||||
min H {minHeightPx}px
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
||||
부 라벨 = backend zone position (top, bottom, primary). */}
|
||||
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
||||
|
||||
@@ -527,6 +527,31 @@ export default function Home() {
|
||||
>
|
||||
{runMeta.status}
|
||||
</span>
|
||||
{runMeta.filtered_section_ids.length > 0 && (
|
||||
<details className="relative">
|
||||
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
|
||||
Filtered: {runMeta.filtered_section_ids.length}
|
||||
</summary>
|
||||
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
|
||||
{runMeta.filtered_section_reasons.map((r, i) => (
|
||||
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
|
||||
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
|
||||
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
|
||||
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
|
||||
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
|
||||
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
|
||||
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
|
||||
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
|
||||
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
|
||||
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
|
||||
<ul className="mt-1 list-disc list-inside text-slate-600">
|
||||
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -207,6 +207,22 @@ export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any):
|
||||
// step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// IMP-10 D-1 : verbatim mirror of step20_slide_status.json.data.filtered_section_reasons[]
|
||||
// schema (src/phase_z2_pipeline.py:2217-2278). `source` / `position` only present on
|
||||
// the override-uncovered additive variant. Strings rendered verbatim — no enum redefinition.
|
||||
export interface FilteredSectionReason {
|
||||
section_ids: string[];
|
||||
merge_type: string | null;
|
||||
template_id: string | null;
|
||||
v4_label: string | null;
|
||||
phase_z_status: string | null;
|
||||
score: number | null;
|
||||
selection_state: string;
|
||||
filter_reasons: string[];
|
||||
source?: string;
|
||||
position?: string | null;
|
||||
}
|
||||
|
||||
export interface RunMeta {
|
||||
run_id: string;
|
||||
mdx_path: string;
|
||||
@@ -214,6 +230,8 @@ export interface RunMeta {
|
||||
status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string;
|
||||
visual_check_passed: boolean;
|
||||
full_mdx_coverage: boolean;
|
||||
filtered_section_ids: string[]; // step20 filtered_section_ids
|
||||
filtered_section_reasons: FilteredSectionReason[]; // step20 filtered_section_reasons
|
||||
preview_url: string; // /data/runs/{runId}/preview.png
|
||||
final_html_url: string; // /data/runs/{runId}/final.html
|
||||
layout_candidates: string[]; // step07 layout_candidates list
|
||||
@@ -393,6 +411,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
status: slideStatus.data?.overall ?? "UNKNOWN",
|
||||
visual_check_passed: slideStatus.data?.visual_check_passed ?? false,
|
||||
full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false,
|
||||
filtered_section_ids: slideStatus.data?.filtered_section_ids ?? [],
|
||||
filtered_section_reasons: slideStatus.data?.filtered_section_reasons ?? [],
|
||||
preview_url: `${base}/preview.png`,
|
||||
final_html_url: `${base}/final.html`,
|
||||
layout_candidates: layout.data?.layout_candidates ?? [],
|
||||
@@ -507,6 +527,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
||||
catalogRegistered: c.catalog_registered,
|
||||
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
|
||||
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
|
||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
|
||||
minHeightPx: c.min_height_px ?? undefined,
|
||||
}));
|
||||
|
||||
const displayStrategy = (
|
||||
|
||||
@@ -127,6 +127,10 @@ export interface FrameCandidate {
|
||||
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
||||
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
||||
catalogRegistered?: boolean;
|
||||
/** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
|
||||
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
||||
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
||||
minHeightPx?: number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# IMP-16-U2 — Phase Z verification wiring design (design-only)
|
||||
|
||||
**Status**: design-only contract. **No runtime wiring lands in this issue.** All wiring is gated behind IMP-07 reverse-path activation (B-2 main). When IMP-07 lands, this doc becomes the binding contract for the Step 1 / 2 / 14 / 21 / 22 changes that consume the IMP-16-U1 surface in `src/phase_z2_verification_utils.py`.
|
||||
|
||||
**Source anchors**
|
||||
- IMP-16 backlog row — [`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):67 (priority ↓ low, hard link IMP-07, source §3 H3 Reference Only).
|
||||
- IMP-07 backlog row — same doc line 51 (status `pending`).
|
||||
- 22-step pipeline anchor — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Steps 1 / 2 / 14 / 21 / 22.
|
||||
- U1 module — `src/phase_z2_verification_utils.py` (u1~u10 ports).
|
||||
- Phase Q reference H3 (Reference Only — do not import) — `src/content_verifier.py`.
|
||||
|
||||
## Gate (hard block — do not merge wiring before this clears)
|
||||
|
||||
- IMP-07 status MUST be `implemented` and `verified` before any code change listed below lands.
|
||||
- Repo grep `html_to_slide_mdx | edited_html_to_mdx | reverse_path` MUST return at least one runtime hit in a non-test module under `src/`.
|
||||
- The reverse-path entry point MUST emit (a) a normalized re-entry MDX string and (b) the upstream generated HTML string, both as deterministic outputs accessible to Step 2 and Step 14 callers.
|
||||
|
||||
## Per-step wiring contract
|
||||
|
||||
### Step 1 — MDX upload (re-entered MDX validation)
|
||||
- Caller : the reverse-path adapter introduced by IMP-07, immediately after it produces a re-entry MDX.
|
||||
- Surface used : u6 `split_into_sentences` (validate that the reverse-path MDX yields at least one sentence after meta-strip + bullet-marker strip).
|
||||
- Behavior : if `split_into_sentences(reentry_mdx)` returns an empty list, the reverse-path adapter MUST raise a deterministic input error before Step 2 starts. No silent fallback. No AI call. No content rewrite.
|
||||
- Trace : `debug.json["step01"]["reentry_sentence_count"]` (additive integer field).
|
||||
|
||||
### Step 2 — MDX normalize (text preservation cross-check)
|
||||
- Caller : `parse_mdx` / `align_sections_to_v4_granularity` post-normalize hook (added only when the input came through the IMP-07 reverse path; original-upload path is unchanged).
|
||||
- Surface used : u8 `verify_text_preservation(reentry_mdx, upstream_generated_html, area_name="reentry_mdx_vs_upstream_html")`.
|
||||
- Threshold : the U1 module default (`_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70`, ported verbatim from Phase Q). Do not redesign in U2.
|
||||
- Behavior : `VerificationResult.passed == False` → adapter aborts the re-entry with the result's `errors` list surfaced; auto pipeline does NOT silently continue. Per `feedback_auto_pipeline_first`, no `review_required` / `review_queue` is inserted — adapter abort is the deterministic outcome.
|
||||
- Trace : `debug.json["step02"]["reentry_text_preservation"] = {passed, score, area_name, missing_count}` (additive; missing sentences themselves NOT serialised, per privacy-by-default).
|
||||
|
||||
### Step 14 — Selenium visual runtime check (invented-text guard)
|
||||
- Caller : the `run_overflow_check` post-render path, ONLY when the run was triggered from the reverse-path re-entry. Original-upload path keeps current Step 14 behavior unchanged (this is NOT an enhancement of Step 14 image/table coverage — that axis belongs to IMP-15).
|
||||
- Surface used : u9 `detect_invented_text(reentry_mdx, final_html)` against the just-rendered `final.html`.
|
||||
- Behavior : the returned `list[str]` is purely *telemetry*. It does NOT change render outcome and does NOT change `compute_slide_status` (Step 20). The reverse-path may consult the list to decide whether to surface a warning at Step 22 — but auto pipeline does not gate on it (per `feedback_auto_pipeline_first` + AI-isolation contract).
|
||||
- Trace : `debug.json["step14"]["reentry_invented_text_fragments"] = list[str]` (additive; already truncated by u9's `_INVENTED_TEXT_TRUNCATE_LEN = 80`).
|
||||
|
||||
### Step 21 — Debug / trace recording (additive only)
|
||||
- Surface used : none (Step 21 consumes the additive fields written by Step 1 / 2 / 14 above).
|
||||
- Behavior : `write_debug_json` MUST treat the new fields as additive — no rename, no removal, no schema regression of existing keys. Missing fields (original-upload path) MUST be absent rather than null, so downstream consumers can distinguish "original upload" from "reverse-path re-entry".
|
||||
- Trace contract : the three additive fields above + a single new flag `debug.json["pipeline"]["reverse_path_reentry"] = bool` (the only schema field that gates the existence of the other three).
|
||||
|
||||
### Step 22 — User confirmation / export (surface, no AI)
|
||||
- Surface used : none directly (Step 22 is UI scope, currently CLI-only — see PHASE-Z-PIPELINE-OVERVIEW Step 22).
|
||||
- Behavior contract for whoever lands Step 22 UI : Step 22 MAY render the additive Step 2 / Step 14 fields read-only. No write-back. No AI call. No content rewrite.
|
||||
|
||||
## Redesigned frame-contract pattern dict (reserved, NOT delivered in U2)
|
||||
|
||||
- Phase Q `REQUIRED_PATTERNS` (Phase Q reference: `src/content_verifier.py:382`) is `body_bg / core / sidebar / footer` — these are Phase Q *area* names, not Phase Z entities. **Values are NOT reused.**
|
||||
- Phase Z replacement will be keyed on (frame_id, frame_slot_id) per the canonical hierarchy `Slide → Zone → Internal Region → Frame → Frame Slot → Content` ([`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) §Operating Principles), and will be sourced from `templates/phase_z2/catalog/frame_contracts.yaml` (Step 0 / Step 10).
|
||||
- **Out of scope for IMP-16-U2.** This belongs to IMP-20 (H2 frame contract validation — same backlog doc line 71). U2 must not ship a pattern dict; U2 must not import or wrap Phase Q `verify_structure` / `verify_area` / `verify_all_areas`.
|
||||
|
||||
## Guardrails (binding)
|
||||
|
||||
- **AI isolation contract** — all wiring above is deterministic. No LLM / Kei / httpx / SSE call on any path. (per `feedback_ai_isolation_contract` + `PZ-1: AI=0 normal`.)
|
||||
- **No-hardcoding** — U2 ports the algorithm. The only literal values reused are the Phase Q H3 thresholds already lifted to named constants in u7 / u8 / u9. No sample-specific value (MDX 03 / 04 / 05) enters U2.
|
||||
- **No `src.content_verifier` import** — under any condition. The U1 module is the sole Phase Z surface.
|
||||
- **No FORBIDDEN_KEI_MEMOS / `generate_with_retry` port** — these are H4 / H5 archive markers and remain out of scope.
|
||||
- **Schema additive only** — debug.json keys listed above are new; no existing key is renamed, removed, or repurposed. (per `feedback_artifact_status_naming` — final.html is not the same axis as preservation / invented-text telemetry.)
|
||||
- **Spacing direction** — N/A for this axis (this is verification, not layout). No common CSS / padding / tolerance shrinking is introduced.
|
||||
- **Status semantics** — Step 20 `compute_slide_status` is NOT changed by U2. Preservation / invented-text fields are *telemetry*; they do not flip `PASS` → `RENDERED_WITH_VISUAL_REGRESSION` on their own.
|
||||
|
||||
## Rollback
|
||||
|
||||
- All changes are additive: the Step 1 input-error path, the Step 2 post-normalize hook, the Step 14 telemetry call, the four new `debug.json` keys.
|
||||
- Rollback = revert the IMP-07 reverse-path entry's call sites; no schema migration needed because the four debug.json keys are gated on `pipeline.reverse_path_reentry`.
|
||||
|
||||
## Open items deferred until IMP-07 lands
|
||||
|
||||
- Exact module path of the IMP-07 reverse-path adapter (TBD by IMP-07).
|
||||
- Whether Step 2's preservation cross-check needs a per-section variant or only a whole-MDX variant — depends on whether IMP-07 emits a single re-entry MDX or per-section MDX fragments.
|
||||
- Whether Step 14's invented-text telemetry should be emitted per `area_name` or only once globally — depends on whether IMP-07's reverse-path produces area-tagged HTML.
|
||||
|
||||
These are NOT resolved here. They are resolved at IMP-07 land time, in a follow-up update to this doc.
|
||||
@@ -0,0 +1,44 @@
|
||||
# IMP-17 — AI repair fallback infrastructure (carve-out)
|
||||
|
||||
**Status**: carve-out, **design-only**. Normal-path AI calls = 0. No runtime fallback code lands until the activation gate clears.
|
||||
|
||||
**Source anchors**
|
||||
- IMP-17 backlog row — [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):68 (carve-out — normal path 밖, soft link IMP-04 + IMP-05).
|
||||
- INSIGHT-MAP §3 — [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) (G3 AI repair fallback infra registry row, normal path = no).
|
||||
- 22-step pipeline — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Step 12 (lines 280-287), Step 16 (lines 318-325), Step 17 (lines 326-333).
|
||||
- Pattern shape reference (Phase Q Archive — link-only, do not port) — `src/content_editor.py:21,318` (httpx + retry shape, imports `sse_utils`) + `src/sse_utils.py:16-50` (SSE token parser).
|
||||
- Route hint emission site — `src/phase_z2_pipeline.py:564` (`restructure` → `ai_adaptation_required` route hint; deterministic emission today, AI consumer deferred to IMP-17).
|
||||
|
||||
## Carve-out boundary
|
||||
|
||||
### Allowed (fallback path only)
|
||||
- **Step 12**: when V4 emits `restructure` (route hint `ai_adaptation_required`) AND deterministic mapping cannot satisfy the frame contract, an AI proposal MAY be invoked to map `content_object` → `Internal Region` / `Frame Slot`. Output = placement proposal at content-object granularity. Frame selection, layout selection, zone topology remain deterministic.
|
||||
- **Step 16 / 17**: when retry router exhausts deterministic actions (zone_ratio_retry / layout_adjust / frame_reselect / details_popup_escalation / image_fit_candidate / frame_internal_fit_candidate) AND user-approved fallback budget remains, an AI proposal MAY be invoked. Output scope identical to Step 12 — content-object placement only.
|
||||
|
||||
### Forbidden (any path)
|
||||
- Normal-path AI calls (Step 12 deterministic mapper, all other steps).
|
||||
- MDX 원문 요약·삭제·재작성 (Phase Z spacing direction guardrail: never compress text).
|
||||
- HTML / CSS 직접 생성, frame contract 신설, layout / zone topology 결정 (Layer-A / Layer-B planning은 코드 영역).
|
||||
- 공통 padding / spacing / tolerance 축소 (PZ-4 — no silent shrink).
|
||||
- 신규 IMP ID 발급 (이 carve-out 은 IMP-17 슬롯에 영구 귀속).
|
||||
|
||||
## Activation gate (3-condition AND — all three required)
|
||||
|
||||
1. **User GO** — 명시적 axis activation 요청. carve-out 자체로는 코드 작성 트리거 안 됨.
|
||||
2. **B4 frame_selection evidence integration complete** — Step 9 frame_selection 의 evidence trace 가 안정화되어야 fallback proposal 이 어떤 frame contract 안에서 동작해야 하는지 식별 가능.
|
||||
3. **IMP-04 (catalog 확장) + IMP-05 (V4 fallback) live** — 카탈로그가 32 frame 으로 확장되고 V4 rank-2/3 fallback 이 활성화돼야 `ai_adaptation_required` 라우트가 실제 의미를 가짐 (현재는 dead-end route hint).
|
||||
|
||||
세 조건 중 하나라도 미충족이면 본 carve-out 은 design-only 상태로 잠겨 있다.
|
||||
|
||||
## Pattern shape reference (link-only, do not import)
|
||||
|
||||
Phase Q `content_editor.py` 는 **Archive Candidate** ([`PHASE-Q-AUDIT.md`](PHASE-Q-AUDIT.md):660-673) — 포팅 대상 아님. 모양만 참조한다:
|
||||
- httpx async streaming + retry 구조 — `src/content_editor.py:21,318` 라인 부근 (import + `stream_sse_tokens` 호출 site).
|
||||
- SSE token 파서 분리 모듈 — `src/sse_utils.py:16-50` (`stream_sse_tokens` 본체).
|
||||
- `EDITOR_PROMPT` (Kei persona) 및 Kei-API endpoint 는 **영구 단절**. 재사용 금지.
|
||||
|
||||
## AI 격리 + Kei persona 단절 contract
|
||||
|
||||
- AI 호출은 normal path 에 없다 (Phase Z 원칙, [memory `feedback_ai_isolation_contract`](../../README.md)).
|
||||
- 출력 단위는 항상 content_object / Internal Region / Frame Slot 또는 restructuring proposal — HTML 구조 / 레이아웃 / 프리셋 결정 X.
|
||||
- Phase Q 자산 (Kei persona prompts, Kei-API endpoint, persona retry semantics) 과 단절. Phase Z 의 fallback runtime 은 별도 prompt / endpoint 설계로 출발한다 (본 carve-out 활성 시).
|
||||
@@ -0,0 +1,64 @@
|
||||
# IMP-18 — Phase Z SVG Coordinate Pre-compute Gap Report
|
||||
|
||||
**Status**: documented (reference-only, dormant)
|
||||
**Scope**: doc-only. No runtime surface modified.
|
||||
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/18
|
||||
**Soft dependency**: IMP-04 (frame_partials registration) — IMP-18 activates only when a SVG-bearing partial lands under `templates/phase_z2/`.
|
||||
|
||||
---
|
||||
|
||||
## A1 — Phase R' source pattern (read-only reference)
|
||||
|
||||
Phase R' implements SVG coordinate pre-compute as a renderer hook. References (do **not** modify):
|
||||
|
||||
- `src/renderer.py:169-207` — `_preprocess_svg_data(block_type, block_data)` — mutates `block_data` with computed coordinates when `block_type` ∈ `SVG_BLOCKS`; warns and falls back on exception.
|
||||
- `src/renderer.py:175` — `SVG_BLOCKS = {"venn-diagram", "relationship"}` — exhaustive type allow-list.
|
||||
- `src/renderer.py:321` — call site inside `render_multi_page()` (`block_data = _preprocess_svg_data(block_type, block_data)`), right before `_resolve_template_path` lookup.
|
||||
- `src/svg_calculator.py:15-156` — five helpers:
|
||||
- L15 `calc_circle_positions(n, center_x, center_y, radius)` — 12 o'clock clockwise N-element layout.
|
||||
- L47 `calc_item_radius(n, base_radius=75.0)` — auto-shrink small-circle radius for crowding.
|
||||
- L59 `calc_orbit_radius(n, base_orbit=120.0)` — auto-expand orbit for crowding.
|
||||
- L70 `calc_outer_radius(n, orbit_radius, item_radius)` — outer enclosing circle, 40 px margin.
|
||||
- L77 `prepare_venn_data(items, center_label, center_sub, description, viewbox_width=600.0, viewbox_height=550.0)` — top-level entry; mutates `items[*].cx/cy/r` and returns `outer_r`/`center_x`/`center_y`/`viewbox_width`/`viewbox_height`.
|
||||
|
||||
## A2 — Phase Z partial SVG inventory (gap)
|
||||
|
||||
Phase Z active partials surface:
|
||||
|
||||
- `templates/phase_z2/families/*.html` — **13** files.
|
||||
- `templates/phase_z2/frames/*.html` — **2** files.
|
||||
- Total surface = **15 partials**.
|
||||
|
||||
SVG usage scan (evidence): `rg "<svg|viewBox" templates/phase_z2/` → **0 matches** (exit 1).
|
||||
|
||||
Closest geometric candidate is `templates/phase_z2/families/construction_goals_three_circle_intersection.html` (frame_id `1171281189`, "cycle-3way-intersection" intent), but it renders three intersecting circles via HTML/CSS — `border-radius:50%` + `linear-gradient` + `::before` outer ring — **not** SVG. The Figma source's six accent kanji circles, six side labels, three decorative rects, and three arcs are explicitly **NOT PROMOTED** at the partial header (compact MDX-mapped focus). No partial currently demands the pre-computed `items[*].cx/cy/r` contract.
|
||||
|
||||
## A3 — IMP-04 activation gate (soft dependency)
|
||||
|
||||
IMP-18 has no Phase Z runtime consumer today. Re-activation triggers:
|
||||
|
||||
1. IMP-04 (frame_partials registration) lands an SVG-bearing partial under `templates/phase_z2/` (e.g., a venn-diagram or relationship frame promoted from Figma).
|
||||
2. The partial declares slots that consume `items[*].cx/cy/r` + `outer_r` + `viewbox_*` (the `prepare_venn_data` return contract).
|
||||
|
||||
Until both conditions hold, IMP-18 stays dormant and this gap report is the sole deliverable.
|
||||
|
||||
## A4 — Phase R' guardrail (read-only lock)
|
||||
|
||||
Per `CLAUDE.md` Phase R' regression prevention rules and the Stage 1/2 exit reports:
|
||||
|
||||
- `src/renderer.py` — read-only. No edit to `_preprocess_svg_data` body, `SVG_BLOCKS` set, or `render_multi_page` call site.
|
||||
- `src/svg_calculator.py` — read-only. No edit to the five helpers or their public signatures.
|
||||
- `templates/phase_z2/families/*.html` (13) + `templates/phase_z2/frames/*.html` (2) — no `<svg>` / `viewBox` insertion in IMP-18 scope. SVG-bearing partial onboarding is owned by IMP-04.
|
||||
- F12 `construction_goals_three_circle_intersection.html` HTML/CSS → SVG migration is **out of scope** (separate post-IMP-04 issue).
|
||||
- No hardcoded SVG coordinates in Phase Z templates — when IMP-18 re-activates, coordinates must be derived from `svg_calculator` helpers (or equivalent forward-port into `phase_z2_renderer`), not hand-copied.
|
||||
|
||||
---
|
||||
|
||||
## Re-activation checklist (future)
|
||||
|
||||
When IMP-04 introduces the first SVG-bearing Phase Z partial:
|
||||
|
||||
- [ ] Identify partial(s) consuming `items[*].cx/cy/r` + `outer_r` + `viewbox_*`.
|
||||
- [ ] Decide port target — extend `phase_z2_renderer` with a `_preprocess_svg_data` analog, or reuse `src/svg_calculator.py` directly.
|
||||
- [ ] Keep Phase R' references untouched.
|
||||
- [ ] Add anchor SHA bump only if runtime source surface changes.
|
||||
@@ -0,0 +1,162 @@
|
||||
# INTEGRATION-AUDIT-01 -- Axis 2 pipeline map (22 issues x 22 steps)
|
||||
|
||||
**Anchor (Stage 1 lock)** :
|
||||
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
|
||||
|
||||
**Companion file** : `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` -- this MATRIX is the spin-off body of REPORT Section 4 (Axis 2). Combined REPORT exceeded the 10 KB readability threshold (REPORT u1 size = 21,070 bytes) at u1 completion, so the grid is housed here per the Stage 2 split rule. REPORT Section 4 carries a back-pointer to this file.
|
||||
|
||||
**Pipeline reference** : `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` (22-step master). Block A (Steps 0-12) = pre-render planning; Block B (Step 13) = render; Block C (Steps 14-22) = post-render telemetry / exception handling.
|
||||
|
||||
**Closed issues under audit (22 total)** : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17 #18 #45 #46 #47 #48 #49`. `#15` = parent; `#45-#49` = execution children. Parent/child de-dup convention (Stage 1 lock) -- `#15` row records integration glue only, no `P` (primary) cells; real code attribution lives in `#45-#48` rows. `#49` = verification-only, no new SHA, re-uses `#48` evidence.
|
||||
|
||||
---
|
||||
|
||||
## Step 0 precondition NOTE (NOT an axis, recorded above the grid)
|
||||
|
||||
Step 0 = `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` precondition block (catalog / contract / matching data / template / asset). Per Stage 2 plan, Step 0 is NOT a grid column; it is recorded here as a precondition note. Closed issues that touched Step 0 :
|
||||
|
||||
| issue | Step 0 touch | scope summary | evidence path |
|
||||
|---|---|---|---|
|
||||
| `#4` | catalog + contract expansion (16 frame_partials + F17 paired_rows_4x2 + frame_contracts.yaml schema) | adds frame DB rows + contract schema fields | `templates/phase_z2/catalog/frame_contracts.yaml` ; `templates/phase_z2/families/*.html` |
|
||||
| `#11` | contract field `min_height_px` exposure | additive contract payload field | `templates/phase_z2/catalog/frame_contracts.yaml` ; `src/phase_z2_pipeline.py` (commit `a79bd8b`) |
|
||||
| `#13` | build-time frame preview generator (salvage of `capture_slide_screenshot`) | precondition asset only (lives in `scripts/`, NOT runtime pipeline) | `scripts/generate_frame_previews.py` (commit `7d5639a`) |
|
||||
| `#14` | slide-base template contract bit (embedded vs standalone) | precondition template surface | `templates/phase_z2/slide_base.html` (commit `7a52ceb`) |
|
||||
| `#18` | doc-only carve-out (no Step 0 code change) | SVG gap report + 1-line backlog status flip | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
|
||||
|
||||
Step 0 touches above are precondition data / template / contract; they do not flow runtime decisions in Steps 1-22 directly, except via consumers already accounted for as Step 5 / 9 / 10 / 12 / 13 / 22 cells in the grid below.
|
||||
|
||||
---
|
||||
|
||||
## Cell legend
|
||||
|
||||
- `P` = primary touch (the issue's own declared scope per body / closing commit)
|
||||
- `A` = adjacent contract (consumer / producer / cross-step dependency surface, not the primary scope)
|
||||
- `.` = not touched (blank-equivalent; dot used for column alignment in monospace renderers)
|
||||
|
||||
Rule applied : if an issue's body or closing commit explicitly names a step or its code file, that is `P`. If the change shape forces the issue to read from or write into another step's contract without being the primary scope, that is `A`. Otherwise `.`.
|
||||
|
||||
Parent `#15` row carries no `P` cells per the Stage 1 de-dup convention; its child rows (`#45-#48`) carry the actual `P` cells.
|
||||
|
||||
---
|
||||
|
||||
## 22 x 22 grid (Step 1 columns -> Step 22 columns)
|
||||
|
||||
Column header shorthand : `S1 = MDX upload | S2 = MDX normalize | S3 = content_object | S4 = section internal composition planning | S5 = V4 evidence | S6 = composition planning | S7 = layout vocabulary | S8 = zone+region ratio | S9 = region-level frame/display | S10 = frame contract | S11 = region-to-slot mapping | S12 = slot payload | S13 = render | S14 = visual_check | S15 = fit_classification | S16 = router | S17 = action | S18 = failure_classify | S19 = next_action | S20 = slide_status | S21 = debug.json | S22 = user UI/export`.
|
||||
|
||||
| issue | S1 | S2 | S3 | S4 | S5 | S6 | S7 | S8 | S9 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S20 | S21 | S22 | row total |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `#2` | . | P | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
|
||||
| `#3` | . | A | P | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
|
||||
| `#4` | . | . | . | . | A | . | . | . | A | P | . | A | A | . | . | . | . | . | . | . | . | . | 5 |
|
||||
| `#5` | . | . | . | . | A | A | . | . | P | . | . | . | . | . | . | A | A | . | . | P | . | . | 6 |
|
||||
| `#6` | A | . | . | . | . | P | A | A | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 7 |
|
||||
| `#7` | A | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | P | 3 |
|
||||
| `#8` | . | . | P | . | A | A | . | . | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 6 |
|
||||
| `#9` | . | . | . | . | . | . | A | P | A | . | . | . | A | . | . | . | A | . | . | . | . | . | 5 |
|
||||
| `#10` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | 2 |
|
||||
| `#11` | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | . | . | . | . | P | 2 |
|
||||
| `#12` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | P | P | A | A | . | . | 6 |
|
||||
| `#13` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | 1 |
|
||||
| `#14` | . | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | . | . | . | . | . | A | 2 |
|
||||
| `#15` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#16` | A | A | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | A | A | 5 |
|
||||
| `#17` | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | A | A | . | . | . | . | . | 3 |
|
||||
| `#18` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | 0 |
|
||||
| `#45` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#46` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#47` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | P | A | . | . | . | . | . | . | 3 |
|
||||
| `#48` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | P | . | 3 |
|
||||
| `#49` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
|
||||
| **col total** | 3 | 4 | 3 | 0 | 3 | 3 | 2 | 2 | 6 | 1 | 0 | 2 | 5 | 9 | 6 | 4 | 4 | 1 | 1 | 3 | 8 | 7 | -- |
|
||||
| **HOTSPOT (>= 4)** | . | H | . | . | . | . | . | . | H | . | . | . | H | H | H | H | H | . | . | . | H | H | -- |
|
||||
|
||||
Cell-count totals : sum of row totals = 77 ; sum of column totals = 77 (cross-check matches; 22 rows x 22 cols = 484 grid positions, of which 77 are non-blank).
|
||||
|
||||
---
|
||||
|
||||
## HOTSPOT enumeration (column total >= 4)
|
||||
|
||||
9 of the 22 steps are HOTSPOT (touched by 4 or more closed issues). Listed in pipeline order :
|
||||
|
||||
| step | col total | touching issues | hotspot meaning |
|
||||
|---|---|---|---|
|
||||
| `S2 MDX normalize` | 4 | `#2 P`, `#3 A`, `#7 A`, `#16 A` | Step 2 is the entry surface for both the Stage 0 chained adapter (`#2`) and downstream content-object trace (`#3`), with reverse-path (`#7`) and verification utility (`#16`) as adjacent consumers. Cross-issue contract = `parse_mdx` output shape stays compatible with `extract_*` semantics. |
|
||||
| `S9 region-level frame/display` | 6 | `#4 A`, `#5 P`, `#6 A`, `#8 A`, `#9 A`, `#11 A` | Step 9 is the heaviest pre-render hotspot. `#5` is primary (V4 fallback / application_plan). `#4 #8 #11` extend the contract / schema feeding Step 9. `#6 #9` exercise the consumer of zone-region geometry. Cross-issue invariant : V4 candidates list + min_height contract + sub_section alias + region ratio must all agree at the Step 9 application_plan boundary. |
|
||||
| `S13 render` | 5 | `#4 A`, `#6 A`, `#8 A`, `#9 A`, `#14 P` | Step 13 is the Jinja2 render surface. `#14` (slide-base iframe mode) is primary. `#4 #6 #8 #9` flow new payload / layout css into the same renderer. Cross-issue invariant : `build_layout_css` + frame_partial + slide_base remain deterministic with no AI in path. |
|
||||
| `S14 visual_check` | 9 | `#12 A`, `#13 A`, `#15 A`, `#16 A`, `#45 P`, `#46 P`, `#47 A`, `#48 A`, `#49 A` | Highest column total. `#15` parent + 5 children (`#45-#49`) all converge here. `#12 #13 #16` are adjacent. Cross-issue invariant : detector producers (`#45 #46`) emit canonical event shape; classifier consumer (`#47`) reads the same shape; debug.json surfaces (`#48`) match -- to be re-verified by Axis 3 (REPORT Section 5). |
|
||||
| `S15 fit_classification` | 6 | `#15 A`, `#45 A`, `#46 A`, `#47 P`, `#48 A`, `#49 A` | `#47` primary (classifier consumes image + table events). All `#15` family is adjacent. Cross-issue invariant : Step 14 producer event keys agree with Step 15 `CONTENT_TYPE_PATTERNS`. |
|
||||
| `S16 router` | 4 | `#5 A`, `#12 P`, `#17 A`, `#47 A` | `#12` primary (3-stage salvage cascade). `#5` bridge fallback adjacent. `#17` gated carve-out adjacent. `#47` classifier output flows into router. Cross-issue invariant : router action map remains deterministic / no AI in normal path. |
|
||||
| `S17 action` | 4 | `#5 A`, `#9 A`, `#12 P`, `#17 A` | `#12` primary (zone_ratio_retry expansion + cross-zone donor + 3-stage cascade). `#9` zone-geometry feeds the same retry surface. `#5` V4 fallback shares `PASS_WITH_FALLBACK` status enum. `#17` is gated. Cross-issue invariant : no common-CSS shrink (per `feedback_phase_z_spacing_direction`). |
|
||||
| `S21 debug.json` | 8 | `#2 A`, `#3 A`, `#15 A`, `#16 A`, `#45 A`, `#46 A`, `#48 P`, `#49 A` | Second-highest column total. `#48` primary (debug.json event surfacing). 7 issues adjacent. Cross-issue invariant : debug.json schema additive only; no key type / semantic conflict (Axis 3 re-verifies this category). |
|
||||
| `S22 user UI/export` | 7 | `#6 A`, `#7 P`, `#8 A`, `#10 P`, `#11 P`, `#14 A`, `#16 A` | Frontend / CLI exit surface. 3 primary (`#7 #10 #11`). 4 adjacent. Cross-issue invariant : `Front/` consumes backend artifacts as read-only payload; backend never reads from frontend except via the reverse path (`#7`). |
|
||||
|
||||
`S2 S9 S13 S14 S15 S16 S17 S21 S22` = 9 distinct hotspot steps (col total >= 4). The col-total HOTSPOT row in the grid carries 9 `H` marks ; counting check matches.
|
||||
|
||||
---
|
||||
|
||||
## Row total HOTSPOT (issues touching the most steps)
|
||||
|
||||
For information only -- this dimension is not an issue-body requirement, but is useful for scope-myopia cross-check with REPORT Section 3 :
|
||||
|
||||
| issue | row total | finding (per REPORT Section 3) |
|
||||
|---|---|---|
|
||||
| `#6` | 7 | Warning -- wide override blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`) -- matrix row total agrees |
|
||||
| `#5` | 6 | OK -- pre-render bridge ; rank-1 path unchanged |
|
||||
| `#8` | 6 | OK -- additive schema with explicit backward-compat alias resolver |
|
||||
| `#12` | 6 | Warning -- large blast radius (4 src + 5 test modules in `56619a0`) -- matrix row total agrees |
|
||||
| `#4` | 5 | OK -- pre-render planning only ; catalog read-only for V4 |
|
||||
| `#9` | 5 | OK -- 8-vocabulary build_layout_css with fixtures |
|
||||
| `#16` | 5 | OK -- utility + design doc only ; gated by `#7` activation |
|
||||
|
||||
The two `Warning` rows in Section 3 (`#6` row total 7 and `#12` row total 6) sit at the top of the row-total ranking -- this is consistent with "wide blast radius" findings in Section 3. The other high-row-total issues (`#5 #8 #4 #9 #16`) are all `OK` per Section 3 because each ships with explicit backward-compat guards / fixtures / gating.
|
||||
|
||||
---
|
||||
|
||||
## Cross-check vs REPORT Section 3 adjacency list
|
||||
|
||||
REPORT Section 3 flagged 9 adjacent-contract pairs for Axis 3 re-verification. Each pair maps onto cells in this grid :
|
||||
|
||||
| Section 3 adjacency pair | matrix evidence |
|
||||
|---|---|
|
||||
| `#2` Step 2 normalize -> `#3` Step 3 content_object | `#2` S2 `P` + `#3` S2 `A` (producer/consumer same column) |
|
||||
| `#3` content_object -> `#8` sub_sections | `#3` S3 `P` + `#8` S3 `P` (both primary on same step -- schema extension) |
|
||||
| `#4` catalog -> `#5` V4 fallback | `#4` S5 `A` + `#5` S5 `A` (both adjacent on same step -- candidate pool dedup) |
|
||||
| `#4` catalog -> `#10 #11` min_height | `#11` S0 (NOTE) ; `#11` S9 `A` (Step 9 consumer of min_height) -- direct adjacency |
|
||||
| `#9` layout vocabulary -> `#12` retry zone-ratio | `#9` S17 `A` + `#12` S17 `P` (consumer/producer same step) |
|
||||
| `#9` -> `#11` Step 9 min_height test | `#9` S9 `A` + `#11` S9 `A` (both adjacent on same step) |
|
||||
| `#45 + #46` Step 14 -> `#47` Step 15 | `#45 #46` S14 `P` + `#47` S15 `P` ; `#47` S14 `A` (cross-step producer/consumer) |
|
||||
| `#48` debug.json -> open `#21` consumer | `#48` S21 `P` ; `#21` is out-of-scope (open) -- no grid row |
|
||||
| `#17` AI carve-out -> `#5 + #4` activation gate | `#17` S12 `P` ; `#17` S16 `A` ; `#17` S17 `A` (gated cells) |
|
||||
|
||||
All 9 adjacency pairs map onto provable cells. Axis 3 (REPORT Section 5) will verify each pair's producer-line / consumer-line on live code.
|
||||
|
||||
---
|
||||
|
||||
## Empty columns (col total = 0)
|
||||
|
||||
- `S4 section internal composition planning` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 4 status `missing` (no closed issue implemented Step 4 yet; it remains in the open backlog).
|
||||
- `S11 content unit / child group -> internal region -> frame slot mapping` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 11 status `missing` (Layer A / Layer B 2-stage placement algorithm not implemented).
|
||||
|
||||
Step 4 and Step 11 are the two `missing` steps in Block A that no closed issue in the audit window addressed. This is expected per the master pipeline status; the audit records absence without claiming a gap (an implementation gap would require an OPEN issue to claim it, which is out of audit scope).
|
||||
|
||||
---
|
||||
|
||||
## Low-touch columns (col total = 1)
|
||||
|
||||
- `S10 frame contract` (1) -- `#4` only ; consistent with `#4` being the catalog/contract owner.
|
||||
- `S18 failure_classify` (1) -- `#12` only ; consistent with `#12` being the retry cascade owner.
|
||||
- `S19 next_action` (1) -- `#12` only ; same.
|
||||
|
||||
---
|
||||
|
||||
## Notes on parent / child row separation
|
||||
|
||||
- `#15` row carries 3 adjacencies (S14 / S15 / S21) and zero `P` cells per the Stage 1 de-dup convention.
|
||||
- `#45 #46 #47 #48` carry the corresponding `P` cells (S14 for `#45 #46` ; S15 for `#47` ; S21 for `#48`).
|
||||
- `#49` (verification-only, no new SHA) mirrors the `#48` adjacency pattern with all-`A` cells -- this is intentional and consistent with the Stage 1 lock that `#49` re-uses `#48` evidence (commit `614c533`). No double-count.
|
||||
|
||||
Sum cross-check : `#15` 3 + `#45` 3 + `#46` 3 + `#47` 3 + `#48` 3 + `#49` 3 = 18 row-total cells across the `#15` family. None of these duplicate code attribution -- only `#45 #46 #47 #48` carry the four `P` cells (one each), totaling 4 primary cells for the family. `#15 #49` carry zero primaries.
|
||||
|
||||
---
|
||||
|
||||
*End of MATRIX. Back to REPORT Section 4 for narrative integration.*
|
||||
@@ -0,0 +1,530 @@
|
||||
# INTEGRATION-AUDIT-01 -- Phase Z closed-issue cumulative consistency review
|
||||
|
||||
## Section 1. Audit anchor
|
||||
|
||||
**Anchor (cited verbatim per Stage 1 exit report)** :
|
||||
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
|
||||
|
||||
**Scope** : 22 closed Gitea issues `#2-#18 + #45-#49` on `Kyeongmin/C.E.L_Slide_test2` against the 22-step Phase Z pipeline (`docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md`, Steps 1-22 plus Step 0 precondition).
|
||||
|
||||
**Mode** : audit-only -- no source code changes. Report-only file changes under `docs/architecture/INTEGRATION-AUDIT-*.md` and one row in `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` (u7).
|
||||
|
||||
**Parent / child relationship** : Gitea `#15` = parent (IMP-15 Step 14 visual_check reinforcement). Execution children = `#45 / #46 / #47 / #48 / #49`. Locked child SHAs (Stage 1 exit report) :
|
||||
- `#45` -> `e9b3d2e` (execution-1, image_aspect_mismatch detection)
|
||||
- `#46` -> `2827622` (execution-2, table_self_overflow detection; commit message label says `IMP-16` but the closed Gitea issue is `#46`; flagged in Section 3)
|
||||
- `#47` -> `535c484` (execution-3, classifier consumes image+table events)
|
||||
- `#48` -> `614c533` (execution-4, debug.json event surfacing + spec taxonomy)
|
||||
- `#49` -> no new SHA (verification-only per `#15` body; re-uses `614c533` evidence)
|
||||
|
||||
**Close timestamp anomaly** (Stage 1 lock, recorded; NOT reopened) :
|
||||
- `#15` closed `2026-05-19T02:35:05+09:00`
|
||||
- `#45 / #46 / #47 / #48` all closed BEFORE `#15` (correct ordering)
|
||||
- `#49` closed `2026-05-19T02:49:56+09:00` -- about 15 minutes AFTER `#15` close (anomaly)
|
||||
- Disposition : record-only in Section 3 / Section 6 finding column; no remediation row in backlog beyond the existing audit completion row (u7).
|
||||
|
||||
**Excluded (open / not in audit)** : `#1, #19, #20, #21, #22, #23, #24, #25, #26, #27, #28, #38, #39, #40, #41, #42, #43, #44`.
|
||||
|
||||
**Sample budget** : `samples/mdx_batch/03.mdx` (smoke) plus `samples/mdx_batch/04.mdx` (details + images). Pipeline runs captured in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## Section 2. Baseline pytest
|
||||
|
||||
**Method** : `pytest -q tests` is the project regression suite. The audit captures it twice -- once before any u5 / u6 / u7 edits, once after Section 7 / 8 grep + render evidence is collected. Equality of both runs proves the audit-only work surface (`docs/architecture/INTEGRATION-AUDIT-*.md` + backlog row in u7) did not perturb production code.
|
||||
|
||||
**Command** : `pytest -q tests` (working dir = repo root `D:\ad-hoc\kei\design_agent\`).
|
||||
|
||||
**Pytest BEFORE audit u5 edits (audit date 2026-05-19)** :
|
||||
- Result : `303 passed in 40.80s`
|
||||
- Last 5 progress dots aggregated to `[100%]` then `Running teardown with pytest sessionfinish...` -- expected suite teardown banner.
|
||||
|
||||
**Pytest AFTER audit u5 edits (post §7 / §8 evidence collection, same audit date)** :
|
||||
- Result : `303 passed in 40.54s`
|
||||
- 303 == 303 ; 0 new failures, 0 skipped, 0 errored. Test count parity proves no test discovery side-effect from new audit docs.
|
||||
|
||||
**Verdict** : OK. Audit-only edits under `docs/architecture/INTEGRATION-AUDIT-*.md` introduce no regression. Baseline stable across u5 assembly.
|
||||
|
||||
---
|
||||
|
||||
## Section 3. Axis 1 -- Scope myopia (22 issues x adjacent-contract cross-reference)
|
||||
|
||||
**Method** : per closed issue, list (a) its own scope as declared in body / backlog row / closing commits, (b) adjacent pipeline contracts the change could have leaked into, (c) downstream consumers of its outputs, (d) finding label `OK` / `Warning` / `Blocker`. Each row cites `src/`, `tests/`, `docs/`, or `templates/` paths.
|
||||
|
||||
**De-dup convention** : `#15` is treated as the *integration parent*; the actual code/test changes are owned by execution children `#45-#49`. `#15` row records integration glue only (parent close evidence + cross-child reconciliation). No change is double-counted across parent + child.
|
||||
|
||||
**Pipeline step shorthand (per `PHASE-Z-PIPELINE-OVERVIEW.md`, full 22-step list)** :
|
||||
- Step 0 precondition / 1 MDX upload / 2 normalize / 3 content_object / 4 internal composition planning / 5 V4 evidence / 6 composition planning / 7 layout vocabulary / 8 zone+region ratio / 9 region-level frame/display / 10 frame contract / 11 region-to-slot mapping / 12 slot payload / 13 render / 14 visual_check / 15 fit_classification / 16 router / 17 action / 18 failure_classify / 19 next_action / 20 slide_status / 21 debug.json / 22 user UI.
|
||||
|
||||
### Section 3 table -- 22 rows
|
||||
|
||||
| # | issue (title) | declared own_scope | adjacent contracts (potential leak surface) | downstream consumers | finding | evidence path |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | Step 2 -- chained `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` with dual-write, preserve raw MDX | Step 3 content_object input shape (raw chunk handoff); Step 21 debug.json schema (`step02_*` keys) | Step 3 (IMP-03 ContentObject extractor); Step 21 trace writer; Step 7/8 layout planner (consumes normalized section list) | OK -- additive; preserves prior `extract_*` semantics via dual-write; no AI in path | `src/phase_z2_pipeline.py` (commit `bac13c0`, +165/-3) |
|
||||
| 2 | `#3` IMP-03 A-1 popup/image/table trace | Step 3 -- normalize popups/images/tables into ContentObject (B1 v0 extension); slide-level rich ContentObject trace | Step 2 normalize output shape (consumer); Step 4 internal composition planning (Step 4 itself still not implemented, so this row only emits trace); Step 21 debug.json schema | Step 4 (not yet implemented; receives data only via trace); Step 21 debug.json (`content_objects` field) | OK -- emits trace without coupling to downstream Step 4 (Step 4 still pending); raw content preserved (no AI summarization; satisfies `feedback_ai_isolation_contract`) | `src/phase_z2_content_extractor.py` + `src/phase_z2_pipeline.py` (commit `fc3f7d8`) |
|
||||
| 3 | `#4` IMP-04 A-2 catalog expansion | Step 0 + Step 9 -- register/expand 16 frame_partials + `frame_contracts.yaml` schema; F17 paired_rows_4x2 + pill alternation + theme | Step 5 V4 evidence (catalog size affects evidence pool); Step 10 frame contract validator (consumes new contracts); Step 12 mapper PAYLOAD_BUILDERS (consumes new schema); Step 13 render template surface (16 new `templates/phase_z2/families/*.html`) | Step 5/9/10/12/13; smoke tests `scripts/smoke_frame_render.py` | OK -- pre-render planning only; catalog is read-only data for V4; frame DB extension matches Step 0 contract; commit `73a98b8` corrected F17 schema after first land (factual_verification path active) | `templates/phase_z2/catalog/frame_contracts.yaml`; `templates/phase_z2/families/*.html`; `src/phase_z2_mapper.py`; `docs/architecture/IMP-04-FRAME-SUITABILITY-MATRIX.md` |
|
||||
| 4 | `#5` IMP-05 A-5 V4 fallback | Step 9 + Step 16/17 -- deterministic V4 candidate bridge (pre-render rank-2/3 fallback); trace schema; dedup invariant test; new `PASS_WITH_FALLBACK` status semantics in Step 20 | Step 5 evidence (candidate dedup must agree with rank-1 path); Step 6 composition (candidates[0] backward-compat); Step 9 application_plan; Step 20 status enum; debug.json trace | Step 9/16/17/20; `tests/test_phase_z2_v4_fallback.py`; `tests/test_catalog_invariant.py` | OK -- pre-render bridge (Block A); deterministic (no AI); rank-1 path unchanged (backward compat per backlog guardrail); dedup invariant test guards collision with `#4` catalog expansion | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` + `src/phase_z2_router.py` (commits `15c5b9a`, `21476ae`, `23d1b25`) |
|
||||
| 5 | `#6` IMP-06 B-1 zone-section override | Step 6 + Step 1/22 input -- CLI arg + composition planner override (`replaced_auto_unit`, `render_records`, plan-aware traces, units rebuild, empty zone) | Step 1 CLI surface; Step 6 `plan_composition` schema (CompositionUnit); Step 7/8/9 downstream (units rebuild forces re-planning); Step 13 render (Catch K render-path) | Step 7/8/9/13; debug.json render_records; `Front/` (later wired via `#8` U3) | Warning -- wide blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`); units-rebuild touches Step 7/8/9 implicitly; verified by `tests/test_phase_z2_section_assignment_override.py` (285 + 42 + 228 lines). No AI; deterministic. Risk = override path widens Step 6 surface where Step 4 is still pending | `src/phase_z2_pipeline.py` (commits `d596fab` `b81e564` `1f15495` `52ccb7f`) |
|
||||
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | Step 22 + Step 1/2 input -- Vite/React `Front/` plus reverse path glue; pipeline re-entry | Step 1 MDX upload; Step 2 normalize (must accept reverse-path MDX); CLI plus service API; `feedback_ai_isolation_contract` (reverse must not invoke AI rewrite) | Step 2 (reverse-path consumer); `Front/client/src/services/designAgentApi.ts`; pipeline CLI | OK -- frontend-shipped (`0f0d3fa`); reverse path schema aligned with `#2` Stage 0 normalize via hard-link declared in backlog. AI isolation preserved (no normal-path LLM in reverse). | `Front/`; `src/phase_z2_pipeline.py`; backlog row IMP-07 |
|
||||
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | Step 3 schema -- sub_sections schema + V4 alias resolver + aligner canonical sub-id + decimal alias guard (N-R5) + frontend wire | Step 3 ContentObject schema (extends `#3`); Step 5 V4 alias surface; Step 6 composition planner (consumer); Step 9 application_plan; `Front/` zoneSections override (U3) | Step 5/6/9/13; `tests/test_phase_z2_subsection_schema.py` (82+100+61 lines) | OK -- additive schema with explicit backward-compat guard (alias resolver at 4 lookup sites); Stage 5 R2 blocker-fix `8f6cffc` force-drills aligner only on override targets (scope contained) | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` (commits `a422d72` `5191aca` `ab2764c` `8f6cffc`) |
|
||||
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | Step 8 -- col-axis solver + per-zone geometry mapper + retry gate; 2-D dynamic dispatch for 5 preset families (single + horizontal-2 + vertical-2 + top-1-bottom-2 + top-2-bottom-1 + left-1-right-2 + left-2-right-1 + grid-2x2) | Step 7 layout vocabulary (consumer); Step 9 region-level (zone geometry feeds region ratios; Step 9 region-level still warning); Step 17 zone_ratio_retry (`#12` IMP-12 retry path); Step 13 render `build_layout_css` | Step 9/13/17; `tests/phase_z2/fixtures/build_layout_css/*.yaml` (16 fixtures); `tests/phase_z2/fixtures/retry_gate/*.yaml` | OK -- all 8 vocabulary entries enabled in build_layout_css; fixtures supply provable diff per preset; no Kei/Phase R' regression (existing `build_containers_type_b` untouched) | `src/phase_z2_pipeline.py` (commits `201099e` PR1, `1fb9732` PR2) |
|
||||
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | Step 20/22 -- frontend read-only display of `filtered_section_reasons` artifact | Step 20 slide_status enum (read-only consumer); `Front/` service API; no backend mutation | `Front/client/src/pages/Home.tsx`; `Front/client/src/services/designAgentApi.ts` | OK -- frontend-only; backend artifact strictly read-only per backlog guardrail | `Front/` (commit `0fb168b`, +45 lines) |
|
||||
| 10 | `#11` IMP-11 D-2 Frame min_height display | Step 22 -- `min_height_px` hint exposed backend to UI; resize hint read-only; Step 9 v4 all-judgments min_height test | Step 0 frame contract (`min_height_px` field); Step 9 region-level (consumer); `Front/` SlideCanvas | Step 9; `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` | OK -- contract read-only; backend exposure is additive payload field (`src/phase_z2_pipeline.py` +32/-12 in `a79bd8b`) | `src/phase_z2_pipeline.py` + `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
|
||||
| 11 | `#12` IMP-12 Step 16/17 retry refinement | Step 16 + Step 17 -- multi-donor + 3-stage salvage cascade; `redistribute` + glue + font compression; new router action; new failure_router taxonomy | Step 14 visual_check (donor selection consumes overflow events); Step 18 failure_classify (cascade adds new failure types); Step 19 next_action (downstream router consumer); Step 20 status semantics; `feedback_phase_z_spacing_direction` (cross-zone redistribute is grant-changing, not common-shrink) | Step 18/19/20; `tests/phase_z2/test_phase_z2_*` (cross_zone, font_step, glue, multi_donor, step17_salvage_chain -- 5 new test modules) | Warning -- large blast radius (4 src files + 5 test modules in `56619a0`); multi-donor introduces cross-zone state in Step 17; verified by 5 dedicated test modules. Risk = cascade may interact with `#5` V4 fallback path in Step 20 status enum (mitigated by separate status enums per `#5` exit report) | `src/phase_z2_failure_router.py` + `src/phase_z2_pipeline.py` + `src/phase_z2_retry.py` + `src/phase_z2_router.py` (commit `56619a0`) |
|
||||
| 12 | `#13` IMP-13 A-3 frame preview consistency | Step 0 + Step 14/21 -- build-time frame preview generator (salvage of `capture_slide_screenshot`) | Step 0 catalog frame_partials (consumer for snapshot); Step 14 visual_check (uses preview for sanity, read-only); no Phase R' regression | `scripts/generate_frame_previews.py`; `tests/test_generate_frame_previews.py` | OK -- build-time only (not in runtime pipeline); deterministic; no Phase R' coupling (script lives in `scripts/`) | `scripts/generate_frame_previews.py` (commit `7d5639a`, 239 LOC + 50 LOC test) |
|
||||
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | Step 13 render -- `slide-base.html` conditional CSS (embedded vs standalone); Step 0 contract bit | Step 0 slide_base template; Step 13 Jinja2 deterministic render; `Front/` SlideCanvas (consumer) | Step 13; `Front/client/src/components/SlideCanvas.tsx`; `tests/phase_z2/test_slide_base_embedded_mode.py` | OK -- render-time contract only; Jinja2 deterministic; embedded mode reduces SlideCanvas friction (34 LOC simplified) | `templates/phase_z2/slide_base.html` + `src/phase_z2_pipeline.py` (commit `7a52ceb`) |
|
||||
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT -- execution children `#45-#49`) | Integration glue only -- *no direct code* under #15; closure depends on `#45-#49` SHAs. De-duped against children (real change attribution = #45-#49 rows below) | Step 14 (parent contract); Step 15 fit_classification consumer; Step 21 debug.json trace; `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (taxonomy row added by `#48`) | Step 15/21; spec doc | Warning -- close-timestamp anomaly only : `#49` closed at `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`. All other children (#45-#48) close BEFORE #15. `#49` body declares verification-only path (no new SHA; re-uses `614c533`), so post-close `#49` close does not leak code into `#15`. Disposition : record-only, no reopen. | `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` Step 14/15; child rows below |
|
||||
| 15 | `#16` IMP-16 B-2 verification helper axis | Step 1/2/14/21/22 -- `phase_z2_verification_utils.py` port + 8 verification test modules + U2 wiring design doc | Step 22 reverse path verification (consumer is `#7` IMP-07 once activated); no normal-path coupling | Step 14/21 trace consumers (utility); future `#7` reverse-path verification | OK -- utility module plus design doc only; no normal-path coupling (gated by `#7` activation); commit `23ba8b6` is design + utility port (335 LOC utility + 8 test modules + wiring doc) | `src/phase_z2_verification_utils.py`; `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` (commit `23ba8b6`) |
|
||||
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out -- outside normal path) | Design-only boundary + 3-cond AND gate (User GO AND B4 frame_selection evidence AND IMP-04/05 live); `httpx` + SSE + retry + JSON parse pattern reference | Step 12 (AI position contract; carve-out body asserts normal path AI = 0); Step 16/17 fallback path (gated activation); `feedback_ai_isolation_contract` (foundational rule); backlog row + INSIGHT-MAP cross-ref | Step 12 (design boundary); future activation gated by 3-cond AND | OK -- design-only carve-out; `src/phase_z2_pipeline.py` change = 1 line (comment anchor for orchestrator test); no runtime AI added | `docs/architecture/IMP-17-CARVE-OUT.md` + `tests/orchestrator_unit/test_imp17_comment_anchor.py` (commit `e10ec36`) |
|
||||
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | Doc-only carve-out -- SVG gap report; `renderer._preprocess_svg_data` pattern reference | Step 0 frame_partials SVG geometry (reference); Phase R' (renderer.py) read-only | doc consumers; backlog row | OK -- doc-only (`docs/architecture/IMP-18-SVG-GAP-REPORT.md` + 1-line backlog status flip from `pending` to `documented`); no code touched | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
|
||||
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection + runtime test | Step 14 -- `image_aspect_mismatch` detection in visual_check; runtime test `test_phase_z2_step14_image_check.py` | Step 15 fit_classification consumer; Step 21 debug.json event surfacing (delegated to `#48`); `#15` parent close evidence | Step 15 (consumer via classifier event); `#47` (classifier integration) | OK -- Step 14 detection only (no Step 15 wiring yet; delegated to `#47`). Test scope local. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_image_check.py` (commit `e9b3d2e`) |
|
||||
| 19 | `#46` (`#15` execution-2) table overflow + element-identity dedup + Selenium test | Step 14 -- `table_self_overflow` detection; element-identity dedup; Selenium integration test | Step 14 dedup logic (must agree with image events from `#45`); Step 15 consumer (delegated to `#47`); `#15` parent | Step 15 (consumer); `#47` | Warning -- commit-message label drift only (Step 14 scope-discipline pattern itself matches `#45`). Commit `2827622` message reads `feat(IMP-16): ...`, which mis-labels the closing Gitea issue (actually closes `#46` = `#15` execution-2; IMP-16 backlog row is the verification utility carved out separately). Audit attribution corrected here; SHA `2827622` is the authoritative anchor. No code/contract leak; risk is record-keeping only. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_table_check.py` (commit `2827622`) |
|
||||
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) + pure-dict test | Step 15 -- classifier consumes image+table events from Step 14; pure-dict test (no Selenium) | Step 14 producers (`#45` + `#46`); Step 15 `CONTENT_TYPE_PATTERNS` taxonomy; Step 16 router (consumer) | Step 16; `tests/phase_z2/test_phase_z2_visual_classifier.py` | OK -- classifier wiring with pure-dict tests isolates Step 15 from Selenium dependency; aligns Step 14 producer to Step 15 consumer (Axis 3 invariant -- to be re-verified in Section 5) | `src/phase_z2_classifier.py` (commit `535c484`) |
|
||||
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc trace + regression | Step 21 debug.json event surfacing + `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` taxonomy row + regression test | Step 21 trace schema (additive); spec doc; regression guard | spec doc consumers; debug.json consumers (Front/, audit tooling) | OK -- 3-line pipeline change + 2 test modules + 1-line spec doc row; smallest blast radius of #15 children | `src/phase_z2_pipeline.py` + `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (commit `614c533`) |
|
||||
| 22 | `#49` (`#15` execution-5) final integration + parent close | Verification-only -- re-uses `#48` `614c533` evidence; no new SHA per `#15` body | `#15` parent close; integration-only | `#15` parent | Warning -- close-timestamp anomaly (closed `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`). Verification-only path has no code change, so anomaly is administrative only; no contract leak. | `#15` body + `614c533` (re-used) |
|
||||
|
||||
### Section 3 finding summary
|
||||
|
||||
- **OK** rows : `#2 #3 #4 #5 #7 #8 #9 #10 #11 #13 #14 #16 #17 #18 #45 #47 #48` (17)
|
||||
- **Warning** rows : `#6` (wide override blast radius, contained by tests); `#12` (multi-donor + cascade, contained by 5 test modules); `#15` (close-timestamp anomaly via `#49`); `#46` (commit-message label drift only, SHA correct); `#49` (close-timestamp anomaly, verification-only) -- 5 rows
|
||||
- **Blocker** rows : 0
|
||||
- **Total** : 17 OK + 5 Warning + 0 Blocker = 22 rows (matches 22 closed issues under audit).
|
||||
- **De-dup audit** : `#15` row carries no code attribution; all code/test work attributed to `#45-#48` (and `#49` = verification-only). No double-count.
|
||||
|
||||
### Section 3 cross-issue scope-myopia adjacency check
|
||||
|
||||
Adjacent-contract pairs flagged for Section 5 Axis 3 re-verification (producer to consumer continuity) :
|
||||
- `#2` Step 2 normalize output -> `#3` Step 3 content_object input
|
||||
- `#3` content_object schema -> `#8` sub_sections schema extension
|
||||
- `#4` catalog expansion -> `#5` V4 fallback candidate pool dedup
|
||||
- `#4` catalog expansion -> `#10`-`#11` `min_height_px` exposure
|
||||
- `#9` layout vocabulary -> `#12` retry zone-ratio donor selection
|
||||
- `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments test
|
||||
- `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router
|
||||
- `#48` debug.json event surfacing -> `#21` (Step 21 debug consumer; open, excluded)
|
||||
- `#17` AI carve-out -> `#5 + #4` activation 3-cond AND gate (gated, not active)
|
||||
|
||||
Axis 3 (Section 5) will verify each pair has agreeing producer-line / consumer-line on the live code.
|
||||
|
||||
---
|
||||
|
||||
## Section 4. Axis 2 -- 22 issues x 22 steps pipeline matrix
|
||||
|
||||
**Split rationale** : at u1 completion the combined REPORT was 21,070 bytes / 136 lines -- over the 10 KB readability threshold defined in the Stage 2 plan. Per the split rule (`combined REPORT >= 10 KB grid moves to MATRIX.md + back-pointer`), the 22 x 22 grid lives in the companion file :
|
||||
|
||||
- `docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md`
|
||||
|
||||
**What MATRIX.md contains** :
|
||||
- Step 0 precondition NOTE (NOT a grid column) -- 5 issues (`#4 #11 #13 #14 #18`) recorded with scope summary + evidence path.
|
||||
- 22 x 22 grid (Step 1 through Step 22 columns x 22 issue rows). Cell legend : `P` = primary touch, `A` = adjacent contract, `.` = no touch. ASCII-only.
|
||||
- Row footer (touched-step count) and column footer (touching-issue count + `H` HOTSPOT marker for col total >= 4).
|
||||
- HOTSPOT enumeration (9 steps : `S2 S9 S13 S14 S15 S16 S17 S21 S22`).
|
||||
- Cross-check against the 9 adjacent-contract pairs flagged in Section 3.
|
||||
- Empty / low-touch column notes (Step 4 and Step 11 are `missing` per PHASE-Z-PIPELINE-OVERVIEW -- 0 touches expected).
|
||||
- Parent/child de-dup sum check : `#15` row carries 3 adjacencies and zero `P` cells ; only `#45 #46 #47 #48` carry the 4 primary cells for the `#15` family ; `#49` is verification-only with all-`A`.
|
||||
|
||||
**Section 4 summary (for readers staying in REPORT)** :
|
||||
- 9 hotspot steps (col total >= 4) : Step 2 (4), Step 9 (6), Step 13 (5), Step 14 (9 highest), Step 15 (6), Step 16 (4), Step 17 (4), Step 21 (8), Step 22 (7).
|
||||
- 2 empty columns : Step 4 + Step 11. Consistent with master pipeline `missing` status -- no audit gap.
|
||||
- Total grid cells filled = 77 (row sum = col sum, cross-checked).
|
||||
- Top row-total issues : `#6` (7), `#5` (6), `#8` (6), `#12` (6) -- the 2 `Warning` rows (`#6 #12`) sit at the top, consistent with Section 3 wide-blast-radius finding.
|
||||
|
||||
---
|
||||
|
||||
## Section 5. Axis 3 -- Cross-issue conflict per invariant category
|
||||
|
||||
**Method** : 6 invariant categories listed in the issue body. Per category, identify producer file:line, consumer file:line, the named state key / contract, the closed issues that touch it, agree-or-conflict verdict, plus grep evidence path. Categories are evaluated against the live tracked code at audit time, not against historical snapshots.
|
||||
|
||||
### 5.1 Invariant category roster (from issue body)
|
||||
|
||||
| # | category | issue-body wording |
|
||||
|---|---|---|
|
||||
| C1 | `debug.json` schema | phase_z2 debug payload paths; no conflicting key type / semantics |
|
||||
| C2 | `visual_check_passed` | `src/phase_z2_pipeline.py` Step 14 / 17; set-site <-> read-site agree |
|
||||
| C3 | `fit_classification` / router | `src/phase_z2_mapper.py` + consumers; labels consistent producer -> consumer |
|
||||
| C4 | Step 14 / 17 / 21 interactions | expected state values stay aligned across the trio |
|
||||
| C5 | Phase R vs Phase Z boundary | no R regression, Z additions don't leak into R |
|
||||
| C6 | template / catalog / frame count | all docs / code use same numbers (family = 13) |
|
||||
|
||||
### 5.2 Producer / consumer / agreement table
|
||||
|
||||
| C# | invariant key | producer (file : line) | consumer(s) (file : line) | touching closed issues | verdict | grep evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| C1 | per-step JSON schema = `step_num`, `step_name`, `step_status`, `pipeline_path_connected`, `input`, `output`, `note`, `data` (locked) | `src/phase_z2_pipeline.py:2593` `_write_step_artifact` definition; locked schema docstring at `2605-2611` (Locked schema lines `2607-2610`) | every step writer in `src/phase_z2_pipeline.py` -- 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780`; `Front/` reads `data/runs/.../steps/*.json`; audit tooling | `#2 step02_*`; `#3 content_objects`; `#5 v4_fallback_summary` + `selection_paths` + `fallback_selection_count`; `#6 render_records`; `#11 min_height_px` payload; `#48 image_events` / `table_events` event surfacing | AGREE -- all step writers go through the single `_write_step_artifact` site with the locked field set; additive `data` payload only; no conflicting key types observed | `Grep _write_step_artifact src/phase_z2_pipeline.py` = 1 definition (line 2593) + 24 call sites = 25 total occurrences (all 24 call sites enumerated in consumer column); all share the same `_write_step_artifact(run_dir, step_num, name, data, *, step_status, pipeline_path_connected, inputs, outputs, note)` kwargs surface |
|
||||
| C2 | `visual_check_passed: bool` set at Step 14 / read at Step 17 | `src/phase_z2_classifier.py:495` `visual_check_passed = bool(overflow.get("passed", False)) and not classifications` returned at `497` | `src/phase_z2_router.py:128` `if fit_classification.get("visual_check_passed", True): ... router_active = False`; `src/phase_z2_pipeline.py:2560` sets `slide_status["visual_check_passed"] = visual_passed`; pipeline summary reads at `4800`, `4804`, `4830` | `#15` parent; `#45` (image_events flip the flag); `#46` (table_events flip the flag); `#47` (classifier widens semantic to `passed AND no classifications`) | AGREE -- single set-site (classifier.py:495) + slide_status mirror (pipeline.py:2560); router.py:128 + pipeline.py:4800/4804/4830 read the same key. Default `.get(..., True)` at router.py:128 is safe because absent key = no classification = pass | `Grep visual_check_passed src` = 14 hits across `classifier.py` + `router.py` + `pipeline.py` -- producer / consumer line set matches |
|
||||
| C3 | `fit_classification` dict keys = `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`; classifier <-> router consumer | `src/phase_z2_classifier.py:496-506` `classify_visual_runtime_check` return dict | `src/phase_z2_router.py:109` `route_fit_classification(fit_classification)`; `src/phase_z2_pipeline.py:4524` `fit_classification = classify_visual_runtime_check(overflow, debug_zones)`; pipeline re-classify after retry at `4582 / 4643`; router decision call at `4540 / 4583 / 4644`; retry consumer `src/phase_z2_retry.py:47` reads `fit_classification` | `#5` (V4 fallback PASS_WITH_FALLBACK semantics); `#12` (retry router multi-donor + cascade); `#15` parent; `#47` (classifier feed); `#48` (debug surfacing) | AGREE -- producer key set is the exact set consumed downstream. NOTE : the issue body says `src/phase_z2_mapper.py` for invariant C3, but the live producer is `src/phase_z2_classifier.py` (`mapper.py` owns slot payload, not fit classification). This is a record-keeping mismatch in the issue body, not a code conflict. Recorded as Section 10 follow-up candidate F-1 | `Grep fit_classification src` = 30 total occurrences across 4 files (`classifier.py` 3 hits incl. docstring/comments; `pipeline.py` 20 hits; `router.py` 5 hits; `retry.py` 2 hits). Active code use sites = producer at `classifier.py:497`; consumers at `router.py:128 / 139` + `pipeline.py 2732 / 4524 / 4540 / 4571 / 4582 / 4583 / 4643 / 4644 / 4754 / 4804 / 4805` + `retry.py:47 / 67`. Remaining occurrences are imports / function-parameter declarations / docstring references |
|
||||
| C4 | Step 14 visual_check overflow events (`image_events`, `table_events`, `passed`) -> Step 15/16 (fit + router) -> Step 17 retry action -> Step 21 debug surface | Step 14 emit sites `src/phase_z2_pipeline.py:2236` (`image_events`), `2282` (`table_events`), `2367 / 2386` (aggregation); Step 15 classifier consumes both event lists at `src/phase_z2_classifier.py:429 / 453`; Step 16 router at `src/phase_z2_router.py:142`; Step 17 retry orchestration at `src/phase_z2_pipeline.py:4571 / 4583 / 4644`; Step 21 trace producer at `src/phase_z2_pipeline.py:4762-4777` (`step21_debug_index.json` + `debug.json` outputs) | Step 21 `debug.json` index reader (`Front/` + audit tooling); pipeline summary 4791-4841 | `#12` (retry cascade Step 17 multi-donor + glue + font compression); `#15 / #45 / #46 / #47 / #48` (Step 14 producer / Step 15 classifier consumer / Step 21 surface); `#10` filtered_section_reasons (Step 22 read-only, Step 21 source) | AGREE with one DOCUMENTED PARTIAL -- Step 21 writer at `pipeline.py:4772` is `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is an *acknowledged* partial state recorded in trace, not a contract conflict between issues. Recorded as Section 6 status row | `Grep step_num.*=.*21\|outputs.*debug\.json src/phase_z2_pipeline.py` = single producer at line 4762-4777 |
|
||||
| C5 | Phase R' (`src/renderer.py`, `src/content_editor.py`, `src/html_validator.py`, `src/block_selector.py`) <-> Phase Z (`src/phase_z2_*.py`) module boundary; no cross-import | `src/phase_z2_pipeline.py` (Phase Z entry) has zero imports of Phase R' modules; verified via `Grep "from renderer\|import renderer\|from phase_q\|from src\.renderer" src/phase_z2_pipeline.py` = `No matches found` | inverse direction `src/renderer.py` and `src/block_selector.py` have zero references to `phase_z2`; verified via `Grep phase_z2 src/renderer.py` = 0 and `Grep phase_z2 src/block_selector.py` = 0 | `#13` (build-time frame preview generator, scripts/ only); `#14` (slide-base iframe mode -- Phase Z only); `#16` (verification utility for Phase Z, no Phase R coupling); `#17` (AI carve-out, design-only no R coupling); `#18` (SVG gap report doc-only) | AGREE -- boundary clean both directions for the closed-issue scope. No Phase R' regression observed; Phase Z additions stay in `phase_z2_*.py` modules | `Grep` results above |
|
||||
| C6 | family templates count vs frame_contracts.yaml count (= 11 in tracked baseline); docs cite "family = 13" including 2 in-progress untracked files | `templates/phase_z2/families/*.html` tracked = 11 (`git ls-files templates/phase_z2/families/` produces 11 entries); `templates/phase_z2/catalog/frame_contracts.yaml` top-level entries = 11 (`grep -cE "^[a-z_]+:$"` = 11) | `src/phase_z2_mapper.py` PAYLOAD_BUILDERS / ITEM_PARSERS / COLUMN_BODY_PARSERS registries (mapper.py:10-16 docstring + 262 / 306 / 332 / 369 / 414 / 424 / 471 registry sites); render surface `templates/phase_z2/families/*.html` | `#4` (16 frame_partials + F17 paired_rows_4x2 schema + theme); `#5` (V4 fallback candidate pool dedup); `#13` (frame preview generator); `#18` (SVG gap report cites `families/*.html (13)`) | AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries. SURFACE NOTE : 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) exist on disk but are NOT in any closed issue and NOT yet contracted. IMP-18 doc "families/*.html (13)" is forward-looking, includes the 2 WIP files. No closed-issue contract is broken; documentation drift is recorded as Section 10 follow-up candidate F-2 | `git ls-files templates/phase_z2/families/` = 11; `ls templates/phase_z2/families/*.html` = 13 (2 untracked); `grep -cE "^[a-z_]+:$" frame_contracts.yaml` = 11 |
|
||||
|
||||
### 5.3 Cross-issue adjacency continuity (Section 3 pairs re-verified)
|
||||
|
||||
| Section 3 adjacent pair | invariant carrying the contract | live continuity verdict |
|
||||
|---|---|---|
|
||||
| `#2` Step 2 normalize -> `#3` Step 3 content_object input | C1 (debug.json `step02_*` + content_objects) | OK -- additive payload, schema preserved via `_write_step_artifact` |
|
||||
| `#3` content_object schema -> `#8` sub_sections schema | C1 + C4 (alias resolver state) | OK -- alias resolver covers 4 lookup sites (REPORT Section 3 row #8 evidence) |
|
||||
| `#4` catalog -> `#5` V4 fallback dedup | C3 + C6 (frame count + classifier consumer) | OK -- candidates[0] backward-compat verified by `tests/test_catalog_invariant.py` (REPORT Section 3 row #5) |
|
||||
| `#4` catalog -> `#10 / #11` `min_height_px` exposure | C1 + C6 | OK -- `min_height_px` is additive read-only field |
|
||||
| `#9` layout vocabulary -> `#12` retry donor selection | C3 + C4 (Step 17 cascade) | OK -- multi-donor cross-zone state lives inside Step 17 retry; spacing direction matches `feedback_phase_z_spacing_direction` (no common-shrink) |
|
||||
| `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments | C6 | OK -- guarded by `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
|
||||
| `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router | C2 + C3 + C4 | OK -- live trace `image_events` / `table_events` enter classifier at `classifier.py:429 / 453`, flow into router at `router.py:142` |
|
||||
| `#48` debug.json event surfacing -> `#21` (open, excluded) | C1 | OK for closed scope -- open consumer `#21` is outside audit window |
|
||||
| `#17` AI carve-out -> `#5 / #4` activation 3-cond AND gate | C5 (boundary not yet crossed) | OK -- gate is *closed* (`User GO AND B4 frame_selection evidence AND IMP-04/05 live`); no normal-path AI active |
|
||||
|
||||
### 5.4 Axis 3 summary
|
||||
|
||||
- 6 invariant categories evaluated. All AGREE for the closed-issue audit scope.
|
||||
- 2 surface notes recorded as Section 10 follow-up candidates :
|
||||
- **F-1** : issue body cites `src/phase_z2_mapper.py` for invariant C3 (`fit_classification`), but the live producer is `src/phase_z2_classifier.py`. Record-keeping correction needed in any future audit charter, not a code conflict.
|
||||
- **F-2** : 2 untracked family templates exist on disk without `frame_contracts.yaml` entries; IMP-18 doc cites "families/*.html (13)" forward-looking. Tracked baseline (11 / 11) is consistent. Contract drift is *not* present for any closed issue; the WIP delta belongs to open work.
|
||||
- 1 documented partial recorded :
|
||||
- Step 21 `_write_step_artifact` at `pipeline.py:4772` carries `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is *self-honest acknowledged* per `feedback_artifact_status_naming`; no cross-issue conflict.
|
||||
- Phase R' <-> Phase Z boundary clean both directions for the 22 closed issues.
|
||||
- 0 Blocker findings in Axis 3.
|
||||
|
||||
### 5.5 Live-grep re-verification stamp (audit date 2026-05-19)
|
||||
|
||||
All numerical claims in Section 5.2 re-verified against live source on the audit date. Commands and results :
|
||||
|
||||
| Claim | Command | Live result | Status |
|
||||
|---|---|---|---|
|
||||
| C1 producer + consumer count | `Grep _write_step_artifact src/phase_z2_pipeline.py -n` | 1 definition (`pipeline.py:2593`) + 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780` = 25 total occurrences | MATCH (Section 5.2 C1 row already lists all 24 call sites) |
|
||||
| C2 consumer scan | `Grep visual_check_passed src` | 14 hits across 3 files (`classifier.py:5`, `pipeline.py:6`, `router.py:3`) | MATCH (Section 5.2 C2 row says "14 hits across `classifier.py` + `router.py` + `pipeline.py`") |
|
||||
| C3 consumer scan | `Grep fit_classification src` | 30 hits across 4 files (`classifier.py:3`, `pipeline.py:20`, `retry.py:2`, `router.py:5`) | MATCH (Section 5.2 C3 row says "30 total occurrences across 4 files") |
|
||||
| C6 family templates -- tracked | `git ls-files templates/phase_z2/families/` | 11 entries | MATCH (Section 5.2 C6 row says "tracked = 11") |
|
||||
| C6 family templates -- on disk | `ls templates/phase_z2/families/*.html | wc -l` | 13 files (11 tracked + 2 WIP untracked : `app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) | MATCH (Section 5.2 C6 row + F-2 follow-up candidate) |
|
||||
| C6 frame_contracts entries | `grep -cE "^[a-z_]+:$" templates/phase_z2/catalog/frame_contracts.yaml` | 11 | MATCH (Section 5.2 C6 row says "= 11") |
|
||||
|
||||
No discrepancy between Section 5.2 grep evidence and live code. Re-verification re-confirms u3 Axis 3 conclusion : 6 invariant categories all AGREE; 2 record-keeping follow-up candidates (F-1, F-2); 1 documented partial (Step 21); 0 Blocker findings.
|
||||
|
||||
---
|
||||
|
||||
## Section 6. Axis 4 -- Backlog vs code reality status matrix
|
||||
|
||||
**Method** : per closed issue, compare (a) `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column at audit time (live read 2026-05-19), (b) live src/ + templates/ + tests/ + docs/ evidence (grep hits + file existence), (c) the audit-allowed status enum `implemented | documented (deferred) | pending`, (d) mismatch flag.
|
||||
|
||||
**Per issue-body rule set** :
|
||||
- `implemented` -> live grep on `src/**` MUST show wired call site(s); not just a single declaration with no consumer.
|
||||
- `documented (deferred)` -> live grep on `src/**` MUST NOT show a production code path that assumes the feature is active (carve-out only).
|
||||
- `pending` -> live grep on `src/**` MUST NOT show wired implementation (or evidence shows incomplete).
|
||||
- `pending -> documented` flip -> reason cited in backlog row must match what `src/**` actually contains.
|
||||
|
||||
### 6.1 Backlog status legend (live read on audit date)
|
||||
|
||||
| backlog status | IMP rows under audit | meaning |
|
||||
|---|---|---|
|
||||
| `documented` | `IMP-18` (1 row) | doc-only carve-out, no production path |
|
||||
| `pending` | `IMP-02` through `IMP-17` (16 rows) | backlog status column has NOT been flipped, despite Gitea issue being closed |
|
||||
| (no backlog row) | `#45 / #46 / #47 / #48 / #49` (5 rows) | execution children of `#15`; backlog tracks the parent `IMP-15` only -- and `IMP-15` is itself still marked `pending` in §2 row |
|
||||
|
||||
**Headline Axis 4 finding** : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column is **stale across the entire closed-issue audit scope** -- 16 of 22 audited issues are flagged `BACKLOG_STALE` (backlog `pending` vs Gitea closed + live code wired); additionally 5 of 22 carry `NO_BACKLOG_ROW` for the `#15` execution children (`#45-#49`), and only 1 of 22 (`#18`) is `AGREE`. Reconciliation: 16 `BACKLOG_STALE` + 5 `NO_BACKLOG_ROW` + 1 `AGREE` = 22 (matches Section 6.3 summary and the 15+1=16 flip plan in §6.3 follow-up reference). This is documentation drift, not a code-side contract conflict; recorded as Section 10 follow-up candidate `F-3`.
|
||||
|
||||
### 6.2 Axis 4 -- 22 row backlog vs code reality matrix
|
||||
|
||||
Status meaning (audit verdict column) :
|
||||
- `implemented_live` = backlog should be flipped to `implemented`; live src/ wiring proves it (grep evidence below).
|
||||
- `documented_live` = backlog `documented` matches code reality (doc-only carve-out; no prod path).
|
||||
- `child_of_parent` = no backlog row by design (execution child of parent IMP-15); status tracked via parent row.
|
||||
|
||||
Mismatch flag :
|
||||
- `BACKLOG_STALE` = backlog says `pending` but code is wired live. Documentation drift only; no code conflict.
|
||||
- `AGREE` = backlog status matches live code reality.
|
||||
- `NO_BACKLOG_ROW` = execution child, child not represented in backlog; not an error, but parent `IMP-15` row is itself stale.
|
||||
|
||||
| # | issue (title) | backlog status (live read) | audit verdict | mismatch flag | live grep evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | `pending` (§1 row 2) | `implemented_live` | BACKLOG_STALE | `Grep "normalize_mdx_content\|extract_major_sections\|extract_conclusion_text" src/` = 24 hits across 6 files (`mdx_normalizer.py`, `phase_z2_content_extractor.py`, `phase_z2_pipeline.py` 9 hits, `pipeline.py`, `pipeline_v2.py`, `section_parser.py`); commit `bac13c0` +165/-3 |
|
||||
| 2 | `#3` IMP-03 A-1 popup/image/table trace | `pending` (§1 row 3) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_content_extractor.py` file exists (Glob hit); commit `fc3f7d8` |
|
||||
| 3 | `#4` IMP-04 A-2 catalog expansion | `pending` (§1 row 4) | `implemented_live` | BACKLOG_STALE | `git ls-files templates/phase_z2/families/` = 11 tracked; `frame_contracts.yaml` top-level entries = 11; commit `73a98b8` corrects F17 schema; matches Axis 3 C6 |
|
||||
| 4 | `#5` IMP-05 A-5 V4 fallback | `pending` (§1 row 5) | `implemented_live` | BACKLOG_STALE | `Grep "PASS_WITH_FALLBACK\|v4_fallback\|fallback_selection" src/` = 28 hits in `phase_z2_pipeline.py`; commits `15c5b9a`, `21476ae`, `23d1b25` |
|
||||
| 5 | `#6` IMP-06 B-1 Zone-section override | `pending` (§1 row 6) | `implemented_live` | BACKLOG_STALE | `Grep "replaced_auto_unit\|render_records\|zone_section_override" src/` = 33 hits in `phase_z2_pipeline.py`; commits `d596fab` / `b81e564` / `1f15495` / `52ccb7f` |
|
||||
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | `pending` (§1 row 7) | `implemented_live` | BACKLOG_STALE | `Front/client/src/services/designAgentApi.ts` file exists (Glob hit); commit `0f0d3fa` |
|
||||
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | `pending` (§1 row 8) | `implemented_live` | BACKLOG_STALE | `Grep "sub_sections\|sub_section_id\|subsection_alias" src/` = 14 hits across `block_assembler.py` (12) + `phase_z2_pipeline.py` (2); commits `a422d72` / `5191aca` / `ab2764c` / `8f6cffc` |
|
||||
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | `pending` (§1 row 9) | `implemented_live` | BACKLOG_STALE | `Grep "build_layout_css\|preset_layout\|zone_geometry" src/` = 11 hits in `phase_z2_pipeline.py`; commits `201099e` / `1fb9732` |
|
||||
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | `pending` (§1 row 10) | `implemented_live` | BACKLOG_STALE | `Grep "filtered_section_reasons" Front/` = 4 hits (`Home.tsx`, `designAgentApi.ts`); + `src/phase_z2_pipeline.py` 6 hits (read-only consumer); commit `0fb168b` +45 lines |
|
||||
| 10 | `#11` IMP-11 D-2 Frame min_height display | `pending` (§1 row 11) | `implemented_live` | BACKLOG_STALE | `Grep "min_height_px" src/` = 50 hits across 6 files (`block_reference.py`, `block_selector.py`, `fit_verifier.py`, `phase_z2_pipeline.py` 21 hits, `phase_z2_retry.py`, `space_allocator.py`); + Front/ 21 hits across 7 files including `SlideCanvas.tsx` (8); commit `a79bd8b` |
|
||||
| 11 | `#12` IMP-12 Step 16/17 retry refinement | `pending` (§2 row 12 IMP-12) | `implemented_live` | BACKLOG_STALE | `Grep "phase_z2_failure_router\|phase_z2_retry\|redistribute\|font_compression" src/` = 63 hits across 7 files (incl. `phase_z2_failure_router.py` 17, `phase_z2_retry.py` 16, `phase_z2_router.py` 6, `phase_z2_pipeline.py` 17); commit `56619a0` |
|
||||
| 12 | `#13` IMP-13 A-3 frame preview consistency | `pending` (§2 row 13) | `implemented_live` | BACKLOG_STALE | `scripts/generate_frame_previews.py` file exists (Glob hit); build-time only (scripts/, not runtime src/) -- matches `documented (deferred)` semantics for *runtime* path but verdict here = implemented_live because the script is the deliverable per issue body; commit `7d5639a` |
|
||||
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | `pending` (§2 row 14) | `implemented_live` | BACKLOG_STALE | `templates/phase_z2/slide_base.html` file exists (Glob hit); `Grep "slide_base\|embedded_mode\|standalone_mode" src/` = 25 hits across 5 files (incl. `block_assembler.py` 8, `phase_z2_pipeline.py` 11); commit `7a52ceb` |
|
||||
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT) | `pending` (§2 row 15) | `implemented_live` (via children `#45-#49`) | BACKLOG_STALE | parent integration only; live code attribution belongs to child rows below (Stage 1 de-dup rule). All 4 child SHAs present in repo (`e9b3d2e` / `2827622` / `535c484` / `614c533`) |
|
||||
| 15 | `#16` IMP-16 B-2 verification helper axis | `pending` (§2 row 16) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_verification_utils.py` file exists (Glob hit); `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` exists; commit `23ba8b6` |
|
||||
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out) | `pending` (§2 row 17) | `documented_live` | BACKLOG_STALE (status semantics) | `docs/architecture/IMP-17-CARVE-OUT.md` file exists (Glob hit); src/ runtime AI = 0 (verified Axis 3 C5 boundary); 3-cond AND gate closed; commit `e10ec36` -- 1 line in `src/phase_z2_pipeline.py` is comment anchor only, not a runtime path. Mismatch FLAG semantics : backlog says `pending`, but reality = `documented (deferred)`. The flag is BACKLOG_STALE *with status-class shift*, distinguished from rows above. |
|
||||
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | `documented` (§2 row 18) | `documented_live` | AGREE | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` file exists (Glob hit); pure doc carve-out; no `src/**` touched; commit `cbbc163` -- the ONLY closed audited issue whose backlog status already reflects code reality |
|
||||
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_image_check.py` file exists (Glob hit); `Grep "image_aspect_mismatch" src/` = 6 hits across `phase_z2_classifier.py` (2 : lines 426, 435) + `phase_z2_pipeline.py` (4 : lines 131, 2236, 2367, 4517); commit `e9b3d2e` |
|
||||
| 19 | `#46` (`#15` execution-2) table_self_overflow detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_table_check.py` file exists (Glob hit); `Grep "table_self_overflow" src/` = 3 hits all in `phase_z2_pipeline.py` (lines 136, 2282, 2386); commit `2827622` (commit-message label drift `feat(IMP-16)` flagged in Section 3 row 19) |
|
||||
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_visual_classifier.py` file exists (Glob hit); `Grep "classify_visual_runtime_check\|CONTENT_TYPE_PATTERNS" src/` = 8 hits across `phase_z2_classifier.py` (4) + `phase_z2_pipeline.py` (4); commit `535c484` |
|
||||
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc + regression | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `Grep "step21_debug_index\|step21_debug" src/` = 1 hit (`phase_z2_pipeline.py`); `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` has taxonomy row (Section 3 row 21 evidence); commit `614c533`; Axis 3 C4 confirms `image_events` / `table_events` end-to-end |
|
||||
| 22 | `#49` (`#15` execution-5) final integration + parent close | no backlog row | `child_of_parent` (verification-only) | NO_BACKLOG_ROW + close-timestamp anomaly (recorded Section 3 row 22) | verification-only per `#15` body; no new SHA; re-uses `614c533` evidence; no fresh grep needed |
|
||||
|
||||
### 6.3 Axis 4 summary
|
||||
|
||||
- **BACKLOG_STALE** rows : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17` = 16 rows (status column reads `pending` but live code is wired; for `#17` the right target status is `documented (deferred)` while for the other 15 it is `implemented`).
|
||||
- **AGREE** rows : `#18` = 1 row (the only issue whose backlog status truthfully reflects code reality).
|
||||
- **NO_BACKLOG_ROW** rows : `#45 #46 #47 #48 #49` = 5 rows (execution children, by-design no backlog row; parent `IMP-15` row exists but is itself BACKLOG_STALE).
|
||||
- **Total** : 16 + 1 + 5 = 22 rows (matches 22 closed issues under audit).
|
||||
- **Implementation-vs-documented split** (audit verdict, ignoring backlog wording) :
|
||||
- `implemented_live` (runtime path wired) : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15(via children) #16` = 15 rows
|
||||
- `documented_live` (doc-only / design-only carve-out, no runtime path) : `#17 #18` = 2 rows
|
||||
- `child_of_parent` (no backlog row, attribution via parent) : `#45-#49` = 5 rows
|
||||
- **0 Blocker findings in Axis 4.** No closed issue is `pending` *and* unimplemented; the only mismatches are documentation drift in the backlog status column.
|
||||
- **Cross-axis consistency** :
|
||||
- Axis 3 C6 frame count `11 tracked / 11 contract entries / 13 on disk (2 WIP)` matches the IMP-04 evidence in Axis 4 row 3 (BACKLOG_STALE but live code present).
|
||||
- Axis 3 C5 boundary (Phase R' <-> Phase Z) clean both ways re-confirms `#17 #18` as documented_live (no R' leak).
|
||||
- Axis 1 (Section 3) `Warning` rows `#6 #12 #15 #46 #49` are all still `implemented_live` in Axis 4 -- the warnings are about *blast radius* and *administrative drift*, not implementation absence.
|
||||
- **Follow-up candidate F-3** (Section 10) : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column needs a sweep to flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for `IMP-17`, and either add child-row stubs for `#45-#49` or add a footnote on the `IMP-15` row pointing at the 5 execution children. This is a single-file documentation-only edit; orthogonal to source-code Stage 3 work; safe under audit-only scope (deferred to a separate follow-up issue, NOT this audit's u7 backlog row).
|
||||
|
||||
---
|
||||
|
||||
## Section 7. Representative pipeline runs
|
||||
|
||||
**Method** : run the Phase Z runtime entry (`python -m src.phase_z2_pipeline <mdx_path> <run_id>`) on the two locked samples (`samples/mdx_batch/03.mdx` smoke + `samples/mdx_batch/04.mdx` details+images). Per run capture (from `data/runs/<run_id>/phase_z2/debug.json`) : top-level keys, `slide_status.visual_check_passed`, `slide_status.overall`, zone count, per-zone frame template + slot keys + slot key count, `slide_status.visual_fail_reasons`, `slide_status.filtered_section_reasons`, `selection_paths`, `image_events` / `table_events` count. Compare invariants across both runs.
|
||||
|
||||
**Audit date** : 2026-05-19. Both runs are fresh on this audit pass (run_ids `audit50_run_03_smoke` + `audit50_run_04_details`).
|
||||
|
||||
### 7.1 Run #1 -- `samples/mdx_batch/03.mdx` (smoke baseline)
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| `run_id` | `audit50_run_03_smoke` |
|
||||
| MDX title parsed | `DX 실행 체계 구축 방안` |
|
||||
| sections parsed | 2 (`03-1`, `03-2`) |
|
||||
| layout preset | `horizontal-2` (composition v0 count-based) |
|
||||
| mode | `composition_v0_layout_8preset` |
|
||||
| debug.json top-level keys | `composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones` (17 keys) |
|
||||
| `slide_status.visual_check_passed` | `True` |
|
||||
| `slide_status.full_mdx_coverage` | `True` |
|
||||
| `slide_status.rendered` | `True` |
|
||||
| `slide_status.overall` | `PASS` |
|
||||
| `slide_status.visual_fail_reasons` | `[]` (empty) |
|
||||
| `slide_status.filtered_section_reasons` | `[]` (empty) |
|
||||
| `slide_status.fallback_selection_count` | `0` |
|
||||
| `fit_classification.visual_check_passed` | `True` (mirrors slide_status) |
|
||||
| `fit_classification.classifications` | `[]` |
|
||||
| `fit_classification.categories_seen` | `[]` |
|
||||
| `router_decision.action` | `None` (no retry path triggered) |
|
||||
| `image_events` count | `0` |
|
||||
| `table_events` count | `0` |
|
||||
| zone count | `2` |
|
||||
| zone[0] (top) | template `three_parallel_requirements` (frame 13), contract `three_parallel_requirements`, label `use_as_is`, slot keys `['pillars', 'title']` (2), sections `['03-1']`, `height_px=228`, `width_px=1180` |
|
||||
| zone[1] (bottom) | template `process_product_two_way` (frame 29), contract `process_product_two_way`, label `use_as_is`, slot keys `['banner_left', 'banner_right', 'process', 'product', 'title']` (5), sections `['03-2']`, `height_px=343`, `width_px=1180` |
|
||||
| `selection_paths` | both `rank_1` (no fallback) |
|
||||
| fail / overflow events | none |
|
||||
|
||||
### 7.2 Run #2 -- `samples/mdx_batch/04.mdx` (details + images)
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| `run_id` | `audit50_run_04_details` |
|
||||
| MDX title parsed | `DX 지연 요인` |
|
||||
| sections parsed | 2 (`04-1`, `04-2`) |
|
||||
| sections aligned | 3 (`04-1`, `04-2-sub-1`, `04-2-sub-2`) -- IMP-08 sub_section schema active |
|
||||
| layout preset | `single` (composition v0 count-based; only 1 unit survived filtering) |
|
||||
| mode | `composition_v0_layout_8preset` |
|
||||
| debug.json top-level keys | identical 17 keys as Run #1 (`composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones`) |
|
||||
| `slide_status.visual_check_passed` | `True` |
|
||||
| `slide_status.full_mdx_coverage` | `False` |
|
||||
| `slide_status.rendered` | `True` (partial artifact -- viable units only) |
|
||||
| `slide_status.overall` | `PARTIAL_COVERAGE` |
|
||||
| `slide_status.visual_fail_reasons` | `[]` (visual side OK; coverage failure is upstream of visual_check) |
|
||||
| `slide_status.filtered_section_reasons` | `[]` (filtering recorded via `selection_paths` chain_exhausted / no_v4_candidate, not via `filtered_section_reasons`) |
|
||||
| `slide_status.fallback_selection_count` | `0` |
|
||||
| `fit_classification.visual_check_passed` | `True` |
|
||||
| `fit_classification.classifications` | `[]` |
|
||||
| `fit_classification.categories_seen` | `[]` |
|
||||
| `router_decision.action` | `None` |
|
||||
| `image_events` count | `0` |
|
||||
| `table_events` count | `0` |
|
||||
| zone count | `1` (single preset) |
|
||||
| zone[0] (primary) | template `bim_issues_quadrant_four` (frame 16), contract `bim_issues_quadrant_four`, label `light_edit`, slot keys `['quadrant_1_body', 'quadrant_1_label', 'quadrant_2_body', 'quadrant_2_label', 'quadrant_3_body', 'quadrant_3_label', 'quadrant_4_body', 'quadrant_4_label', 'title']` (9), sections `['04-2-sub-2']`, `height_px=585`, `width_px=1180` |
|
||||
| `selection_paths` | `04-1=chain_exhausted`, `04-2-sub-1=chain_exhausted`, `04-2-sub-2=rank_1`, `04-2=no_v4_candidate` |
|
||||
| fail / overflow events | none |
|
||||
|
||||
### 7.3 Cross-run invariants
|
||||
|
||||
| invariant | run #1 (03.mdx) | run #2 (04.mdx) | verdict |
|
||||
|---|---|---|---|
|
||||
| debug.json top-level key set | 17 keys (above) | identical 17 keys | AGREE -- Step 21 schema stable across both runs (Axis 3 C1) |
|
||||
| `slide_status` schema keys | 19 keys (`visual_check_passed`, `full_mdx_coverage`, `rendered`, `overall`, `visual_fail_reasons`, `filtered_section_ids`, `filtered_section_reasons`, `aligned_section_ids`, `covered_section_ids`, `adapter_needed_count`, `adapter_needed_units`, `content_truncated_count`, `content_truncated_units`, `fallback_selection_count`, `fallback_selections`, `fallback_used`, `selection_path`, `selection_paths`, `note`) | identical 19 keys | AGREE -- slide_status surface stable (Axis 3 C2 + C4) |
|
||||
| `fit_classification` shape | `{visual_check_passed, classifications, summary, categories_seen, ...}` -- matches Axis 3 C3 row | identical shape | AGREE -- classifier output schema invariant |
|
||||
| `visual_check_passed` semantic | `True` AND `classifications=[]` -> overall `PASS` | `True` AND `classifications=[]` AND `full_mdx_coverage=False` -> overall `PARTIAL_COVERAGE` | AGREE -- visual side passing under both runs; `PARTIAL_COVERAGE` is composition-planner side (upstream of Step 14), so visual_check_passed does NOT contradict overall status (Axis 3 C2 verdict re-confirmed) |
|
||||
| zone count vs layout preset | `horizontal-2` -> 2 zones (top + bottom) | `single` -> 1 zone (primary) | AGREE -- preset-to-zone arity matches IMP-09 B-4 vocabulary (Axis 1 row 8) |
|
||||
| frame contract resolution | both zones resolved to a contract id (rank_1 path) | only 1 of 4 selection paths resolved (3 `chain_exhausted` / `no_v4_candidate`) | DIFF EXPECTED -- 04.mdx exhibits v4 candidate gap; this is the composition-planner maturity gap (not in any closed-issue scope). Not a contract conflict. |
|
||||
| `image_events` / `table_events` arity | both = 0 | both = 0 | AGREE -- neither sample triggers Step 14 image/table self-overflow; `#45` `image_aspect_mismatch` and `#46` `table_self_overflow` event-arrays exist in the schema and are *correctly empty* when no overflow is detected |
|
||||
| router action triggered | `None` | `None` | AGREE -- Step 16 router is dormant when classifications=[] (Axis 3 C3 verdict re-confirmed; `#12` retry cascade not exercised by these samples) |
|
||||
| pipeline final banner | `PASS` (full MDX coverage + visual OK) | `PARTIAL_COVERAGE` (visual OK + composition-planner filter) | self-honest status naming per [[feedback_artifact_status_naming]] |
|
||||
|
||||
### 7.4 Run-level findings
|
||||
|
||||
- Both runs pass the visual_check axis (Axis 3 C2 contract). `visual_check_passed=True` agrees between `fit_classification` and `slide_status` mirror in both runs.
|
||||
- 04.mdx `PARTIAL_COVERAGE` is a composition-planner side filter (3 sections drop to `chain_exhausted` / `no_v4_candidate` before reaching Step 14). This is NOT an audit Blocker because (a) no closed issue under audit targets composition-planner coverage, (b) the status field is self-honestly named `PARTIAL_COVERAGE` rather than misnamed `PASS` (matches [[feedback_artifact_status_naming]]).
|
||||
- Step 21 `debug.json` writer surfaces a stable 17-key top-level surface across both runs; Axis 3 C1 invariant re-confirmed at runtime.
|
||||
- Zero Blocker findings in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## Section 8. Anti-hardcoding grep checklist
|
||||
|
||||
**Method** : run the 6 anti-hardcoding patterns enumerated in the Issue #50 body. For each, capture the live hit set, classify hits (Phase Z scope vs. legacy Phase R'/Q out-of-scope vs. docstring/comment vs. test fixture), then return a verdict. Raw output preserved at `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt` (evidence-only, not staged for commit per Stage 3 directive).
|
||||
|
||||
**Audit date** : 2026-05-19. Searched against tracked source on this date.
|
||||
|
||||
### 8.1 Checklist
|
||||
|
||||
| # | pattern (issue body) | expected | live hit count (src/) | hit classification | verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| G1 | `grep -E 'if .* == ["'\\''].*\.mdx' src/` | 0 hits | 0 | none | PASS |
|
||||
| G2 | `grep -E 'OVERRIDES\s*=\s*\{' src/` | each match sample-agnostic | 0 | none | PASS (vacuously sample-agnostic) |
|
||||
| G3 | `grep -E '재구성\|건설산업 DX\|BIM' src/` -- sample text leak | 0 hits | 31 source hits across 14 `.py` files (binary `.pyc` matches ignored) | (a) 20 hits in legacy Phase R'/Q files (`block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3) -- pre-Phase-Z; not in audit window; (b) 11 hits in Phase Z files (`phase_z2_content_extractor.py` 7 -- all inside `if __name__ == "__main__"` self-test data blocks at lines 466/493/511/556/565/573/591; `phase_z2_failure_router.py:123` 1 -- internal taxonomy string `"topology 부터 재구성. frame_reselect 는 그 다음 단계"`; `phase_z2_mapper.py:519/529` 2 -- docstring examples; `phase_z2_retry.py:59` 1 -- docstring). Per-file count sum = 20 + 11 = 31, matching the live total. | PASS for the audit scope -- **0 closed-issue (#2-#18 + #45-#49)** introduces new sample-specific hardcoded BIM/재구성/건설산업 string literals into runtime code paths. All 11 Phase Z hits are docstring/taxonomy/self-test fixtures, none injected into runtime contracts. Legacy 20 hits are out of audit window. Recorded as Section 10 follow-up candidate `F-4` for future cleanup (doc-only, optional). |
|
||||
| G4 | `grep -E 'height\s*=\s*720\|aspect\s*=\s*0\.5' src/` -- magic literal pinning | 0 hits | 0 | none | PASS |
|
||||
| G5 | sample paths come from CLI args / config, not hardcoded | sample-agnostic | 4 occurrences across 2 files (`src/block_assembler.py:1390/1393` + `src/image_utils.py:62/65`) | all 4 hits use `samples/mdx_batch` as one of several **generic asset search directories** alongside `samples/images` (image asset discovery fallback). The directory is treated as a discovery namespace, not as a path to a specific MDX file. CLI entry (`src/phase_z2_pipeline.py:4861`) takes `mdx_path` as positional arg -- no hardcoded MDX path on the runtime entry. | PASS -- sample-agnostic asset discovery default; not a per-sample pin. |
|
||||
| G6 | `tests/` : sample-specific fixtures only under `tests/fixtures/`, not in production pipeline | fixtures isolated | `tests/fixtures/` directory does not exist; closest hits = `tests/phase_z2/test_pz2_vu_integration.py:6, 82` referencing `samples/mdx_batch/02.mdx` as smoke-coverage MDX | the references in `test_pz2_vu_integration.py` are inside a verification-utility integration test (`#16` IMP-16 scope). The test file is named with the test prefix and lives in `tests/phase_z2/`, so pytest discovery treats it as a test, not as a production module. No production pipeline file imports a sample MDX path literal. | PASS WITH NOTE -- no `tests/fixtures/` directory exists today; the existing integration tests already keep sample references inside `tests/phase_z2/test_*.py`, which discharges the spirit of the rule. Optional follow-up: formalize a `tests/fixtures/` directory if sample inventory grows. Recorded as Section 10 follow-up candidate `F-5` (low priority, doc-only). |
|
||||
|
||||
### 8.2 Anti-hardcoding verdict
|
||||
|
||||
- 4 patterns PASS cleanly with 0 hits (G1, G2, G4) and 1 PASS with sample-agnostic hits (G5).
|
||||
- 1 pattern PASS-for-audit-scope with classification (G3) : 11 Phase Z hits are all docstrings/taxonomy/self-test fixtures; 20 legacy hits are out of the 22-closed-issue audit window. Per-file counts sum to 31, matching the live grep total. No closed issue introduces new hardcoded sample text into a runtime code path.
|
||||
- 1 pattern PASS WITH NOTE (G6) : `tests/fixtures/` directory not yet established; existing integration test references stay inside `tests/phase_z2/`. Already aligned with the spirit of the rule.
|
||||
- **0 Blocker findings in Section 8.**
|
||||
- Cross-axis : the F-4 / F-5 follow-up candidates are doc-only optional cleanup; they do not alter any closed-issue contract.
|
||||
|
||||
---
|
||||
|
||||
## Section 9. Final decision
|
||||
|
||||
**Decision** : **CONDITIONAL GO for #19**.
|
||||
|
||||
### 9.1 Summary across all 4 audit axes + supporting sections
|
||||
|
||||
| section | axis | Blocker | Warning | OK | follow-up candidates |
|
||||
|---|---|---|---|---|---|
|
||||
| §3 | Axis 1 -- scope myopia | 0 | 5 (`#6 #12 #15 #46 #49`) | 17 | none Blocker; warnings are blast-radius + administrative drift |
|
||||
| §4 + MATRIX.md | Axis 2 -- 22 x 22 pipeline matrix | 0 | (9 hotspot steps, 2 expected-empty cols) | 22 issues mapped | none Blocker; hotspots match expected Step 14 / 21 attention |
|
||||
| §5 | Axis 3 -- cross-issue conflict (6 invariants) | 0 | 0 | 6 categories AGREE | F-1 (body cites mapper.py; live producer is classifier.py for `fit_classification`); F-2 (13 family templates on disk vs. 11 tracked / contracted -- 2 WIP outside any closed issue) |
|
||||
| §6 | Axis 4 -- backlog vs code reality | 0 | -- | 1 AGREE, 16 BACKLOG_STALE (doc drift), 5 NO_BACKLOG_ROW (by design for `#45-#49`) | F-3 (backlog status sweep : flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for IMP-17, footnote `IMP-15` row with 5 children) |
|
||||
| §7 | representative runs (03.mdx + 04.mdx) | 0 | -- | both runs visual_check_passed = True; debug.json schema stable; 04.mdx PARTIAL_COVERAGE is composition-planner side (no audit-window contract conflict) | none new |
|
||||
| §8 | grep checklist (6 patterns from issue body) | 0 | -- | G1/G2/G4/G5 PASS; G3/G6 PASS WITH NOTE | F-4 (legacy Phase R'/Q BIM literals -- optional cleanup); F-5 (formalize `tests/fixtures/` -- optional) |
|
||||
| §2 | baseline pytest | -- | -- | 303 passed BEFORE + 303 passed AFTER audit | none |
|
||||
|
||||
### 9.2 Blocker tally
|
||||
|
||||
- **0 Blocker** findings across all four axes and all supporting sections.
|
||||
- 5 Warning rows in §3 are about blast radius (`#6 #12`), administrative commit-label drift (`#46`), and parent/child close-timestamp anomaly (`#15 #49`). None of them indicate broken code contracts.
|
||||
- All BACKLOG_STALE rows in §6 are documentation drift, not implementation absence. Live grep on `src/**` confirms each closed issue is wired (or carved-out as designed for `#17 #18`).
|
||||
- 5 follow-up candidates (F-1 .. F-5) are all doc-only. None require source code changes.
|
||||
|
||||
### 9.3 Why CONDITIONAL GO, not unconditional GO
|
||||
|
||||
Audit found zero Blocker, but the conditions for upgrading to unconditional GO are not met because:
|
||||
|
||||
1. **F-3 (backlog sweep)** is the largest doc-drift surface (16 of 22 audited rows mislabeled). Issue #19 will read the backlog when scoping next-step coverage; running #19 against a stale backlog risks a planner who treats already-implemented features as still pending. The F-3 follow-up should be filed and merged before -- or at minimum in parallel with -- #19 Stage 2 planning.
|
||||
2. **F-2 (family template count drift)** matters if #19 touches the catalog / `frame_contracts.yaml` (likely). The audit confirms 11 tracked entries are consistent today, but #19 should reconcile the 2 WIP files (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) before adding any new family templates.
|
||||
3. **F-1 (record-keeping for invariant C3 producer file path)** -- a small but real mismatch between the issue body wording (`src/phase_z2_mapper.py`) and the live producer (`src/phase_z2_classifier.py`). Should be fixed in the audit charter / spec doc before the next integration audit so future audits do not repeat the same drift check.
|
||||
|
||||
F-4 / F-5 are optional and do not gate #19.
|
||||
|
||||
### 9.4 Conditions to satisfy for #19 progression
|
||||
|
||||
- File F-1 / F-2 / F-3 as Section 10 follow-up issues (text-only drafts produced in u6).
|
||||
- F-3 backlog sweep should land before #19 Stage 2 (so #19 plans against accurate status).
|
||||
- F-2 family template reconciliation should land before #19 introduces new family templates (whichever comes first).
|
||||
- F-1 is a one-line spec-doc edit, can land any time before the next INTEGRATION-AUDIT issue is opened.
|
||||
|
||||
### 9.5 Decision sentence
|
||||
|
||||
> **Issue #19 is approved for entry under CONDITIONAL GO**, with the explicit dependency that follow-up F-3 (backlog status sweep) must land before #19 Stage 2 planning consumes the backlog, and F-2 (family template reconciliation) must land before any #19 work that extends the catalog. No production source code change is required from this audit. Pytest baseline stable (303 passed BEFORE + AFTER).
|
||||
|
||||
---
|
||||
|
||||
## Section 10. Follow-up issue drafts (text-only, not auto-posted)
|
||||
|
||||
**Scope rule (Stage 2 u6 contract)** : per-draft fields = `title` + `source_axis` (1-4) + `scope` (what files / what change) + `evidence_link` (REPORT section that produced the finding). **No Gitea post.** Final disposition of each candidate is the orchestrator / human triage decision after #50 closes; this REPORT only records the audit-side text.
|
||||
|
||||
Five candidates were produced by Axes 1-4. F-3 + F-2 + F-1 are blocking conditions for upgrading §9 CONDITIONAL GO -> unconditional GO for #19; F-4 + F-5 are optional housekeeping. None require source-code changes inside this audit.
|
||||
|
||||
### 10.1 F-1 -- audit charter record-keeping : invariant C3 producer file path
|
||||
|
||||
- **title** : `[AUDIT-CHARTER-FIX] invariant C3 (fit_classification) producer cited as src/phase_z2_mapper.py; live producer is src/phase_z2_classifier.py`
|
||||
- **source_axis** : Axis 3 (cross-issue conflict, invariant category C3) -- recorded in §5.2 C3 row + §5.4 follow-up bullet F-1.
|
||||
- **scope** :
|
||||
- one-line fix in any future INTEGRATION-AUDIT-* issue body or in `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` if it cites the wrong file for `fit_classification` producer.
|
||||
- replace text `src/phase_z2_mapper.py` -> `src/phase_z2_classifier.py` *only in the context of `fit_classification` invariant* (mapper.py legitimately owns slot payload and registries, so do not blanket-rename).
|
||||
- update audit charter template (if one exists) so the next integration audit does not repeat the drift check.
|
||||
- **scope-lock** : **doc-only**, zero `src/**` / `templates/**` / `tests/**` edits.
|
||||
- **evidence_link** :
|
||||
- REPORT §5.2 row C3 (issue body wording vs. live producer).
|
||||
- REPORT §5.4 follow-up bullet F-1.
|
||||
- Live producer site : `src/phase_z2_classifier.py:495-497` (return dict with `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`).
|
||||
- **priority / gating** : low priority on its own; required for charter cleanliness; **not** a blocker for #19 Stage 2.
|
||||
|
||||
### 10.2 F-2 -- family template count reconciliation : 11 tracked / 11 contracted / 13 on disk
|
||||
|
||||
- **title** : `[FAMILY-TEMPLATE-RECONCILE] templates/phase_z2/families/ has 13 .html files on disk but 11 tracked + 11 frame_contracts entries; 2 WIP files (app_sw_package_vs_solution.html, pre_construction_model_info_stacked.html) untracked`
|
||||
- **source_axis** : Axis 3 (invariant category C6 template / catalog / frame count) -- recorded in §5.2 C6 row + §5.4 follow-up bullet F-2 + §6.3 Axis 4 cross-axis consistency bullet.
|
||||
- **scope** :
|
||||
- decide whether the 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) should be (a) tracked + contracted (add to `frame_contracts.yaml`, add to `git ls-files`), (b) removed if abandoned, or (c) explicitly noted as in-progress with a parent issue.
|
||||
- reconcile the IMP-18 SVG-gap report doc citation `families/*.html (13)` against whichever decision is chosen (so the doc count matches code reality).
|
||||
- **scope-lock** : touches `templates/phase_z2/families/*.html`, `templates/phase_z2/catalog/frame_contracts.yaml`, `docs/architecture/IMP-18-SVG-GAP-REPORT.md`. **Must NOT be folded into #19 silently**: any catalog growth needs a dedicated issue per [[feedback_workflow_atomicity_rules]] (one commit = one decision).
|
||||
- **evidence_link** :
|
||||
- REPORT §5.2 row C6 ("AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries").
|
||||
- REPORT §5.5 row "C6 family templates -- on disk" (`ls templates/phase_z2/families/*.html` = 13).
|
||||
- REPORT §6.3 Axis 4 cross-axis consistency bullet (matches IMP-04 evidence).
|
||||
- **priority / gating** : **must land before #19 introduces any new family template** (per §9.3 condition 2). Until #19's catalog touch surface is known, this can be filed independently.
|
||||
|
||||
### 10.3 F-3 -- backlog status sweep : 15 rows pending->implemented + 1 row pending->documented(deferred) + IMP-15 children footnote
|
||||
|
||||
- **title** : `[BACKLOG-STATUS-SWEEP] PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md has 16 of 22 audited rows mislabeled as pending; flip 15 to implemented, 1 (IMP-17) to documented (deferred), footnote IMP-15 with 5 execution children`
|
||||
- **source_axis** : Axis 4 (backlog vs code reality) -- recorded in §6.1 headline finding + §6.2 22-row matrix + §6.3 follow-up candidate F-3 + §9.1 §6 row + §9.3 condition 1.
|
||||
- **scope** :
|
||||
- **15 rows** to flip `pending` -> `implemented` : IMP-02, IMP-03, IMP-04, IMP-05, IMP-06, IMP-07, IMP-08, IMP-09, IMP-10, IMP-11, IMP-12, IMP-13, IMP-14, IMP-15 (parent), IMP-16.
|
||||
- **1 row** to flip `pending` -> `documented (deferred)` : IMP-17 (status-class shift; runtime AI = 0, 3-cond AND gate closed; matches §6.2 row 16).
|
||||
- **IMP-15 row** : add inline footnote citing the 5 execution children commits `#45 (e9b3d2e)`, `#46 (2827622)`, `#47 (535c484)`, `#48 (614c533)`, `#49 (verification-only, re-uses 614c533)`. Either as a footnote on the IMP-15 row or as 5 child stub rows -- pick one and apply consistently.
|
||||
- **IMP-18 row** : leave as `documented` (already AGREE per §6.2 row 17).
|
||||
- **scope-lock** : single-file edit to `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`. **Doc-only**, zero `src/**` / `templates/**` / `tests/**` edits. Must be filed as a separate Gitea issue with its own Stage 5 commit (not merged into #19 or any other improvement issue).
|
||||
- **evidence_link** :
|
||||
- REPORT §6.1 headline finding (16 BACKLOG_STALE + 5 NO_BACKLOG_ROW + 1 AGREE = 22).
|
||||
- REPORT §6.2 22-row matrix (per-row grep evidence + commit SHAs).
|
||||
- REPORT §6.3 follow-up reference.
|
||||
- REPORT §9.1 / §9.3 condition 1 ("F-3 backlog sweep should land before #19 Stage 2 planning consumes the backlog").
|
||||
- **priority / gating** : **highest of the 5 candidates**. **Must land before #19 Stage 2 planning** (per §9.3 condition 1) so that #19's planner reads accurate `implemented` / `documented (deferred)` status and does not treat already-wired features as still pending.
|
||||
|
||||
### 10.4 F-4 -- legacy Phase R' / Q sample-literal cleanup (OPTIONAL)
|
||||
|
||||
- **title** : `[LEGACY-LITERAL-CLEANUP] 20 hits of 재구성 / 건설산업 DX / BIM across 10 legacy Phase R'/Q files (block_assembler_b2.py, block_matcher_tfidf.py, block_reference.py, content_editor.py, design_director.py, design_tokens.py, fit_verifier.py, frame_extractor.py, kei_client.py, pipeline.py)`
|
||||
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist) -- recorded in §8.1 row G3 + §8.2 third bullet + §9.1 §8 row.
|
||||
- **scope** :
|
||||
- per-file review of the 20 legacy hits to determine which are docstrings / comments (keep), legacy taxonomy (keep with annotation), or true sample-literal pins (remove or generalize).
|
||||
- per-file counts to triage : `block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3.
|
||||
- **NOT** touching the 11 Phase Z hits (`phase_z2_content_extractor.py` 7 self-test data, `phase_z2_failure_router.py:123` taxonomy, `phase_z2_mapper.py:519/529` docstring examples, `phase_z2_retry.py:59` docstring) -- those passed audit verdict G3.
|
||||
- **scope-lock** : potentially touches legacy `src/**` files NOT in the Phase Z 22-step pipeline. Must be filed as a deliberate cleanup issue with its own scope-lock. If any file flagged here turns out to be on a live Phase Z code path on review, demote the candidate or split it.
|
||||
- **evidence_link** :
|
||||
- REPORT §8.1 row G3 (per-file count breakdown, audit date 2026-05-19).
|
||||
- REPORT §8.2 third bullet (20 legacy + 11 Phase Z = 31, reconciliation).
|
||||
- Raw grep output : `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt`.
|
||||
- **priority / gating** : **optional, low priority, doc-only follow-up note**. Does NOT gate #19; §8 verdict already PASS for audit scope. Recorded for completeness so future audits do not re-discover the same 20-hit baseline.
|
||||
|
||||
### 10.5 F-5 -- formalize tests/fixtures/ directory (OPTIONAL)
|
||||
|
||||
- **title** : `[TESTS-FIXTURES-FORMALIZE] tests/fixtures/ directory does not exist; sample MDX references currently live in tests/phase_z2/test_pz2_vu_integration.py`
|
||||
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist G6) -- recorded in §8.1 row G6 + §8.2 fourth bullet.
|
||||
- **scope** :
|
||||
- if-and-only-if sample inventory grows beyond what fits inside `tests/phase_z2/test_*.py` files, formalize a `tests/fixtures/` directory holding sample-specific fixtures.
|
||||
- migrate existing `samples/mdx_batch/02.mdx` references in `tests/phase_z2/test_pz2_vu_integration.py:6, 82` only if migration is part of a broader test-fixture refactor (otherwise leave them as integration smoke).
|
||||
- update the issue-body rule wording to acknowledge that `tests/phase_z2/test_*.py` already discharges the spirit of "no sample-specific fixtures in production pipeline".
|
||||
- **scope-lock** : touches `tests/fixtures/` (new directory if filed) + the cited test files. Must NOT be folded into any unrelated test refactor.
|
||||
- **evidence_link** :
|
||||
- REPORT §8.1 row G6 verdict "PASS WITH NOTE".
|
||||
- REPORT §8.2 fourth bullet (`tests/fixtures/` not yet established).
|
||||
- **priority / gating** : **optional, very low priority**. Filing is only justified when sample inventory grows; the current state is already aligned with the spirit of the rule.
|
||||
|
||||
### 10.6 Follow-up summary
|
||||
|
||||
| candidate | source axis | doc-only? | gates #19? | priority |
|
||||
|---|---|---|---|---|
|
||||
| F-1 audit-charter producer file path | Axis 3 (§5) | YES | NO | low (charter cleanup) |
|
||||
| F-2 family template count reconcile | Axis 3 (§5) | NO -- touches templates / catalog / docs | gate IF #19 extends catalog | medium |
|
||||
| F-3 backlog status sweep | Axis 4 (§6) | YES | YES -- must land before #19 Stage 2 plan | **highest** |
|
||||
| F-4 legacy R'/Q literal cleanup | §8 (anti-hardcoding) | NO -- legacy src/ touch surface | NO | low (optional) |
|
||||
| F-5 tests/fixtures/ formalize | §8 (anti-hardcoding) | NO -- tests/ migration | NO | very low (optional) |
|
||||
|
||||
- **Counts** : 5 candidates total. 3 are blocking conditions for upgrading §9 CONDITIONAL GO to unconditional GO for #19 (F-3 hard-gates, F-2 conditional-gates on catalog touch, F-1 nice-to-have before next audit). 2 are optional housekeeping (F-4, F-5).
|
||||
- **Compliance with Stage 2 u6 contract** : per-draft fields (title / source_axis / scope / evidence_link) populated for each of F-1 .. F-5. **Zero auto-posts** -- this section is text-only. Filing decisions = orchestrator / human after #50 closes.
|
||||
- **AI-isolation contract** : none of the 5 follow-up candidates require AI on a normal path. F-2 / F-4 / F-5 are scope decisions to be made by a human reviewer. Compatible with [[feedback_ai_isolation_contract]] and PZ-1 (AI = 0 on normal path).
|
||||
@@ -120,7 +120,7 @@
|
||||
| A-4 slide-base iframe mode | Step 13 | §2.8 I2 (renderer.py slide-base 사용 호출 지점) | pending | yes (UI/backend) |
|
||||
| Step 14 visual_check 보강 | Step 14, 21 | §2.7 H1 (`content_verifier` utilities Reference Only) | pending | yes (deterministic) |
|
||||
| B-2 verification 보조 | Step 1, 2, 14, 21, 22 | §2.7 H3 (text 추출 / 정규화 / 비교 utility) | pending | yes (UI/backend) |
|
||||
| AI repair fallback infra | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) |
|
||||
| IMP-17 AI repair fallback infra (carve-out — see [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md)) | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) |
|
||||
| I3 SVG 좌표 보강 | Step 0, 9 | §2.8 I3 (`renderer._preprocess_svg_data`) | pending | yes (deterministic) |
|
||||
| I4 zone 비중 분배 | Step 8 | §2.8 I4 (`renderer._group_blocks_by_area`) | pending | yes (deterministic) |
|
||||
| H2 frame contract validation | Step 10 | §2.7 H2 (`content_verifier.verify_structure` pattern) | pending | yes (deterministic) |
|
||||
|
||||
@@ -93,6 +93,7 @@ action :
|
||||
| `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] |
|
||||
| `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 |
|
||||
| `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 |
|
||||
| `image_aspect_mismatch` | Post-render `fail_reasons` signal — Step 14 visual_runtime_check 가 이미지 frame slot 의 rendered aspect ratio 와 declared aspect ratio 불일치를 감지 (router-routed fit_classifier 출력 아님; 별도 image_events stream 으로 표면화) |
|
||||
|
||||
### 3.2 분류 우선순위 (위에서 아래로)
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
| IMP-14 | A-4 slide-base iframe mode | Step 13 | §3 새로 만들기 | ↓ low | `slide-base.html` conditional CSS (embedded vs standalone) | Claude / Phase R' HTML generation 회귀 X / Jinja2 deterministic | none | pending |
|
||||
| IMP-15 | Step 14 visual_check 보강 | Step 14, 21 | §3 H1 Reference Only | medium | image_aspect_mismatch / tabular_overflow 검사 추가 | AI/Kei classification 회귀 X / deterministic 검사 + trace | soft link: IMP-01 (Step 14 측정/trace layer 공유) | pending |
|
||||
| IMP-16 | B-2 verification 보조 axis | Step 1, 2, 14, 21, 22 | §3 H3 Reference Only | ↓ low | B-2 reverse path 의 verification 보조. main reverse path 는 IMP-07, 본 issue 는 text/visual/trace 검증 layer | AI/Kei verification 회귀 X / utility deterministic | hard link: IMP-07 (B-2 main 활성 시점 의미) | pending |
|
||||
| **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | pending |
|
||||
| IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | pending |
|
||||
| **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | [carve-out boundary + activation gate](IMP-17-CARVE-OUT.md) (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live — full def in u2 doc) — `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | pending |
|
||||
| IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 — [gap report](IMP-18-SVG-GAP-REPORT.md) | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | documented |
|
||||
| IMP-19 | I4 zone 비중 분배 | Step 8 | §3 Reference Only | ↓ low | `renderer._group_blocks_by_area` 패턴 reference — zone-level ratio 분배 | Phase O 컨테이너 회귀 X / 직접 통합 X | soft link: IMP-09 (zone 비중 분배 영역 공유) | pending |
|
||||
| IMP-20 | H2 frame contract validation | Step 10 | §3 Reference Only | ↓ low | `content_verifier.verify_structure` pattern reference — Phase Z frame contract 검증 pattern | Phase Q `REQUIRED_PATTERNS` 값 회귀 X / Phase Z 자체 pattern dict 설계 | soft link: IMP-04 (확장 catalog 적용 시 검증 범위 확대) | pending |
|
||||
|
||||
@@ -132,3 +132,5 @@ Gitea Issues 활성 sanity check 별 GO ─┐
|
||||
(Codex 1차 → Claude 재검토 → Codex 재검증
|
||||
→ 100% 합의 → 구현 → 검증 → close)
|
||||
```
|
||||
|
||||
- **IMP-50 audit (2026-05-19)** — [INTEGRATION-AUDIT-01-REPORT.md](INTEGRATION-AUDIT-01-REPORT.md) — Decision: **CONDITIONAL GO for #19** (F-3 backlog status sweep + F-2 family template reconciliation required before #19 Stage 2) — Stage 5 commit SHA: 8c7d693
|
||||
|
||||
+1682
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
"""IMP-13 build-time preview.png renderer for figma_to_html_agent/blocks/<frame_id> (u1-u6)."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_BLOCKS_DIR = REPO_ROOT / "figma_to_html_agent" / "blocks"
|
||||
DEFAULT_MANIFEST = DEFAULT_BLOCKS_DIR / "_preview_manifest.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameRow:
|
||||
frame_id: str
|
||||
block_dir: Path
|
||||
index_html_path: Path
|
||||
preview_png_path: Path
|
||||
has_index: bool
|
||||
has_preview: bool
|
||||
|
||||
|
||||
def discover(blocks_dir: Path) -> List[FrameRow]:
|
||||
if not blocks_dir.is_dir():
|
||||
return []
|
||||
rows: List[FrameRow] = []
|
||||
for entry in sorted(blocks_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
idx, png = entry / "index.html", entry / "preview.png"
|
||||
rows.append(FrameRow(entry.name, entry, idx, png, idx.is_file(), png.is_file()))
|
||||
return rows
|
||||
|
||||
|
||||
def _build_driver() -> Any:
|
||||
"""Headless Chrome driver. Mirrors the run_overflow_check chromedriver-candidate + headless options pattern.
|
||||
Inline per Stage 2 (no shared module). Per-frame window-size is set by the caller (u3), not here."""
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
candidates = [REPO_ROOT / "chromedriver", REPO_ROOT / "chromedriver.exe"]
|
||||
last_err: Exception | None = None
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
try:
|
||||
return webdriver.Chrome(service=Service(str(path)), options=options)
|
||||
except Exception as exc: # noqa: BLE001 — propagate via aggregated error
|
||||
last_err = exc
|
||||
try:
|
||||
return webdriver.Chrome(options=options)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
|
||||
|
||||
|
||||
def render_one(driver: Any, row: FrameRow) -> tuple[int, int, Path]:
|
||||
"""Render row.index_html_path -> row.preview_png_path via WebElement screenshot. Returns (w, h, path) or raises.
|
||||
Driver is injected (caller owns lifecycle). .slide bbox drives window-size; no hardcoded slide dimensions."""
|
||||
if not row.has_index:
|
||||
raise FileNotFoundError(f"missing index.html: {row.index_html_path}")
|
||||
from selenium.webdriver.common.by import By
|
||||
driver.get(row.index_html_path.resolve().as_uri())
|
||||
driver.set_script_timeout(15)
|
||||
driver.execute_async_script(
|
||||
"const cb=arguments[arguments.length-1];"
|
||||
"(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>cb(true));"
|
||||
)
|
||||
rect = driver.execute_script(
|
||||
"const el=document.querySelector('.slide');"
|
||||
"if(!el)return null;"
|
||||
"const r=el.getBoundingClientRect();"
|
||||
"return [Math.round(r.width), Math.round(r.height)];"
|
||||
)
|
||||
if not rect:
|
||||
raise RuntimeError(f".slide not found in {row.index_html_path}")
|
||||
w, h = int(rect[0]), int(rect[1])
|
||||
driver.set_window_size(w, h)
|
||||
el = driver.find_element(By.CSS_SELECTOR, ".slide")
|
||||
row.preview_png_path.write_bytes(el.screenshot_as_png)
|
||||
return w, h, row.preview_png_path
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Stale-detect short-circuit: True iff preview.png mtime >= index.html mtime AND sha256 matches last_entry.
|
||||
Returns False when prior entry is absent, preview.png is missing, preview is older than index, or hash differs."""
|
||||
if last_entry is None or not row.has_index or not row.has_preview:
|
||||
return False
|
||||
try:
|
||||
idx_mtime = row.index_html_path.stat().st_mtime
|
||||
png_mtime = row.preview_png_path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
if png_mtime < idx_mtime:
|
||||
return False
|
||||
recorded = last_entry.get("index_sha256")
|
||||
if not recorded:
|
||||
return False
|
||||
return _sha256_file(row.index_html_path) == recorded
|
||||
|
||||
|
||||
def categorize(rows: List[FrameRow]) -> Dict[str, List[FrameRow]]:
|
||||
"""Bucket discover() rows so nothing is silently skipped (Stage 2 guardrail).
|
||||
renderable = has_index (eligible for render or skipped_unchanged decision in u6).
|
||||
missing_index_html = no index.html (catalog gap; IMP-04 follow-up).
|
||||
orphan = preview.png exists without index.html (subset of missing_index_html; stale artifact to flag).
|
||||
Buckets are intentionally non-disjoint: orphan is a subset of missing_index_html,
|
||||
matching the Stage 2 evidence counts (renderable=20, missing_index_html=13, orphan=1)."""
|
||||
renderable = [r for r in rows if r.has_index]
|
||||
missing = [r for r in rows if not r.has_index]
|
||||
orphan = [r for r in missing if r.has_preview]
|
||||
return {"renderable": renderable, "missing_index_html": missing, "orphan": orphan}
|
||||
|
||||
|
||||
def _load_manifest(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _render_entry(row: FrameRow, w: int, h: int) -> Dict[str, Any]:
|
||||
return {"status": "rendered", "index_sha256": _sha256_file(row.index_html_path),
|
||||
"index_mtime": row.index_html_path.stat().st_mtime,
|
||||
"preview_mtime": row.preview_png_path.stat().st_mtime,
|
||||
"viewport": {"w": w, "h": h}}
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="generate_frame_previews", description="IMP-13 build-time preview.png renderer.")
|
||||
p.add_argument("--blocks-dir", type=Path, default=DEFAULT_BLOCKS_DIR)
|
||||
p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
args = p.parse_args(list(argv) if argv is not None else None)
|
||||
rows = discover(args.blocks_dir)
|
||||
if args.dry_run:
|
||||
wi = sum(1 for r in rows if r.has_index)
|
||||
wp = sum(1 for r in rows if r.has_preview)
|
||||
print(f"discovered: total={len(rows)} with_index_html={wi} with_preview_png={wp}")
|
||||
return 0
|
||||
prev_frames = _load_manifest(args.manifest).get("frames") or {}
|
||||
buckets = categorize(rows)
|
||||
frames: Dict[str, Dict[str, Any]] = {}
|
||||
counts = {"rendered": 0, "skipped_unchanged": 0, "error": 0}
|
||||
driver = None
|
||||
try:
|
||||
for r in buckets["renderable"]:
|
||||
last = prev_frames.get(r.frame_id) if isinstance(prev_frames, dict) else None
|
||||
if is_unchanged(r, last):
|
||||
frames[r.frame_id] = {**last, "status": "skipped_unchanged"}
|
||||
counts["skipped_unchanged"] += 1
|
||||
continue
|
||||
if driver is None:
|
||||
driver = _build_driver()
|
||||
try:
|
||||
w, h, _ = render_one(driver, r)
|
||||
frames[r.frame_id] = _render_entry(r, w, h)
|
||||
counts["rendered"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
frames[r.frame_id] = {"status": "error", "error": str(exc)}
|
||||
counts["error"] += 1
|
||||
finally:
|
||||
if driver is not None:
|
||||
try: driver.quit()
|
||||
except Exception: pass
|
||||
orphan_ids = {r.frame_id for r in buckets["orphan"]}
|
||||
for r in buckets["missing_index_html"]:
|
||||
frames[r.frame_id] = {"status": "orphan" if r.frame_id in orphan_ids else "missing_index_html", "has_preview": r.has_preview}
|
||||
summary = {"total": len(rows), "renderable": len(buckets["renderable"]), "missing_index_html": len(buckets["missing_index_html"]), "orphan": len(buckets["orphan"]), **counts}
|
||||
payload = {"schema": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "blocks_dir": str(args.blocks_dir), "summary": summary, "frames": frames}
|
||||
args.manifest.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
print(f"coverage: total={summary['total']} renderable={summary['renderable']} rendered={counts['rendered']} skipped_unchanged={counts['skipped_unchanged']} missing_index_html={summary['missing_index_html']} orphan={summary['orphan']} error={counts['error']}")
|
||||
return 1 if counts["error"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+61
-11
@@ -344,7 +344,7 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
|
||||
Returns:
|
||||
dict :
|
||||
visual_check_passed : Selenium 통과 여부
|
||||
visual_check_passed : Selenium 통과 여부 (overflow.passed AND no classifications)
|
||||
classifications : 각 overflow event 의 분류 결과 list
|
||||
summary : 텍스트 요약 (n events, categories seen)
|
||||
categories_seen : 등장한 카테고리 unique list
|
||||
@@ -353,6 +353,12 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
divergence + region / slot_assignment / rejection
|
||||
count) — passed 여부 무관 항상 surface
|
||||
"""
|
||||
# Deferred import — phase_z2_pipeline imports this module at module top, so
|
||||
# a top-level `from phase_z2_pipeline import ...` would be circular. Pulled
|
||||
# in at call time so both modules are fully loaded. Tolerances are owned by
|
||||
# phase_z2_pipeline (single source of truth — see IMP-15 실행-1/2).
|
||||
from phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
|
||||
|
||||
# placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface.
|
||||
# passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치).
|
||||
placement_diagnostics = [
|
||||
@@ -364,15 +370,9 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
for dz in (debug_zones or [])
|
||||
]
|
||||
|
||||
if overflow.get("passed", False):
|
||||
return {
|
||||
"visual_check_passed": True,
|
||||
"classifications": [],
|
||||
"summary": "visual check passed — no overflow to classify",
|
||||
"categories_seen": [],
|
||||
"unclassified_signals": [],
|
||||
"placement_diagnostics": placement_diagnostics,
|
||||
}
|
||||
# IMP-15 실행-3 (issue #47): no early-return on overflow.passed=True.
|
||||
# image_events / table_events scans below run unconditionally; the final
|
||||
# visual_check_passed is widened to: overflow.passed AND no classifications.
|
||||
|
||||
# zone position → debug_zones 매핑 (capacity_fit_status 추출용)
|
||||
capacity_status_by_position: dict[str, Optional[str]] = {}
|
||||
@@ -423,6 +423,53 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
cls["scroll_height"] = c.get("scrollHeight")
|
||||
classifications.append(cls)
|
||||
|
||||
# IMP-15 실행-3 (issue #47): image_events scan — image_aspect_mismatch emitter.
|
||||
# delta is None ⇒ skip (image not loaded; no false positive).
|
||||
# |delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit classification.
|
||||
for ev in (overflow.get("image_events") or []):
|
||||
delta = ev.get("delta")
|
||||
if delta is None:
|
||||
continue
|
||||
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
|
||||
classifications.append({
|
||||
"category": "image_aspect_mismatch",
|
||||
"source": "image_event",
|
||||
"zone_position": ev.get("zone_position"),
|
||||
"zone_template_id": ev.get("zone_template_id"),
|
||||
"src": ev.get("src"),
|
||||
"natural_ratio": ev.get("natural_ratio"),
|
||||
"rendered_ratio": ev.get("rendered_ratio"),
|
||||
"delta": delta,
|
||||
"rule_applied": (
|
||||
f"|delta|={abs(delta):.4f} > IMAGE_ASPECT_DELTA_TOL="
|
||||
f"{IMAGE_ASPECT_DELTA_TOL} (IMP-15 실행-3)"
|
||||
),
|
||||
})
|
||||
|
||||
# IMP-15 실행-3 (issue #47): table_events scan — tabular_overflow emitter.
|
||||
# wrapper_clipped_index is not None ⇒ skip (clipped_inner already covers this
|
||||
# case via zone cascade; honor dedup contract from pipeline producer).
|
||||
# excess_x or excess_y > TABLE_SCROLL_TOL_PX ⇒ emit tabular_overflow.
|
||||
for ev in (overflow.get("table_events") or []):
|
||||
if ev.get("wrapper_clipped_index") is not None:
|
||||
continue
|
||||
excess_x = ev.get("excess_x") or 0
|
||||
excess_y = ev.get("excess_y") or 0
|
||||
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
|
||||
classifications.append({
|
||||
"category": "tabular_overflow",
|
||||
"source": "table_event",
|
||||
"zone_position": ev.get("zone_position"),
|
||||
"zone_template_id": ev.get("zone_template_id"),
|
||||
"excess_x": excess_x,
|
||||
"excess_y": excess_y,
|
||||
"rule_applied": (
|
||||
f"table self-overflow — excess_x={excess_x} or excess_y="
|
||||
f"{excess_y} > TABLE_SCROLL_TOL_PX={TABLE_SCROLL_TOL_PX} "
|
||||
f"(wrapper not clipped; IMP-15 실행-3)"
|
||||
),
|
||||
})
|
||||
|
||||
# slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조)
|
||||
unclassified: list[dict] = []
|
||||
slide_m = overflow.get("slide") or {}
|
||||
@@ -443,8 +490,11 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
})
|
||||
|
||||
categories = sorted({c["category"] for c in classifications})
|
||||
# IMP-15 실행-3 (issue #47): widened semantic — overflow.passed alone is not
|
||||
# enough; any image/table classification also flips visual_check_passed.
|
||||
visual_check_passed = bool(overflow.get("passed", False)) and not classifications
|
||||
return {
|
||||
"visual_check_passed": False,
|
||||
"visual_check_passed": visual_check_passed,
|
||||
"classifications": classifications,
|
||||
"summary": (
|
||||
f"{len(classifications)} overflow event(s) classified, "
|
||||
|
||||
+101
-21
@@ -7,20 +7,29 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
|
||||
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
||||
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
|
||||
|
||||
**잠근 매핑** (사용자 잠금 — 2026-04-29) :
|
||||
**잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
|
||||
|
||||
| failure_type | next_proposed_action |
|
||||
| failure_type | next_proposed_action |
|
||||
|---|---|
|
||||
| donor_slack_insufficient | layout_adjust |
|
||||
| no_donor_candidates | layout_adjust |
|
||||
| rerender_still_fails | frame_reselect |
|
||||
| not_attempted | none |
|
||||
| donor_slack_insufficient | cross_zone_redistribute |
|
||||
| no_donor_candidates | cross_zone_redistribute |
|
||||
| cross_zone_redistribute_insufficient | glue_compression |
|
||||
| glue_absorption_insufficient | font_step_compression |
|
||||
| font_step_insufficient | layout_adjust |
|
||||
| rerender_still_fails | frame_reselect |
|
||||
| not_attempted | none |
|
||||
|
||||
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) :
|
||||
**escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
|
||||
```
|
||||
layout_adjust (가장 가벼움 — zone 배치만 변경)
|
||||
cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (중간 — frame 자체 변경)
|
||||
glue_compression (SPACING_GLUE envelope, frame-scoped)
|
||||
↓ 그래도 안 되면
|
||||
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
|
||||
↓ 그래도 안 되면
|
||||
layout_adjust (zone topology 변경)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (V4 top-k 의 다른 frame)
|
||||
↓ 그래도 안 되면
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
@@ -53,26 +62,65 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
||||
"현재 frame/zone 조합이 content 와 맞지 않음"
|
||||
),
|
||||
"cross_zone_redistribute_insufficient": (
|
||||
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
|
||||
"could not find a feasible role-height adjustment within the frame envelope"
|
||||
),
|
||||
"glue_absorption_insufficient": (
|
||||
"glue_compression salvage step failed — frame envelope cannot absorb "
|
||||
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
|
||||
),
|
||||
"font_step_insufficient": (
|
||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
|
||||
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
|
||||
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
|
||||
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
|
||||
|
||||
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
}
|
||||
|
||||
|
||||
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
|
||||
|
||||
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
"donor_slack_insufficient": "layout_adjust",
|
||||
"no_donor_candidates": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
"not_attempted": "none",
|
||||
"donor_slack_insufficient": "cross_zone_redistribute",
|
||||
"no_donor_candidates": "cross_zone_redistribute",
|
||||
"cross_zone_redistribute_insufficient": "glue_compression",
|
||||
"glue_absorption_insufficient": "font_step_compression",
|
||||
"font_step_insufficient": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
"not_attempted": "none",
|
||||
}
|
||||
|
||||
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"donor_slack_insufficient": (
|
||||
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
|
||||
"(layout_adjust). frame 자체는 아직 의심 대상 X"
|
||||
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
|
||||
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||
),
|
||||
"no_donor_candidates": (
|
||||
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
|
||||
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
|
||||
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
|
||||
"재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||
),
|
||||
"cross_zone_redistribute_insufficient": (
|
||||
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
|
||||
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
|
||||
),
|
||||
"glue_absorption_insufficient": (
|
||||
"frame 여백 envelope 도 부족 → font_step_compression "
|
||||
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
|
||||
),
|
||||
"font_step_insufficient": (
|
||||
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
|
||||
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
|
||||
),
|
||||
"rerender_still_fails": (
|
||||
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
||||
@@ -85,10 +133,19 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
}
|
||||
|
||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
||||
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
|
||||
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
|
||||
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
|
||||
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
|
||||
# the deterministic surface availability, not whether a given pipeline run has
|
||||
# already invoked it.
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
"none": "n/a",
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +163,29 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
||||
if retry_trace.get("retry_passed"):
|
||||
return None
|
||||
|
||||
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
|
||||
if retry_trace.get("salvage_passed"):
|
||||
return None
|
||||
|
||||
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
||||
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||
if salvage_steps:
|
||||
last = salvage_steps[-1] or {}
|
||||
if not last.get("passed"):
|
||||
action = (last.get("action") or "").lower()
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False. raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
return {
|
||||
@@ -204,7 +284,7 @@ def route_retry_failure(failure_type: str) -> dict:
|
||||
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
||||
next_action, "unknown"
|
||||
),
|
||||
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)",
|
||||
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+585
-26
@@ -58,10 +58,19 @@ from phase_z2_classifier import classify_visual_runtime_check
|
||||
from phase_z2_router import route_fit_classification
|
||||
from phase_z2_retry import (
|
||||
DEFAULT_SAFETY_MARGIN_PX,
|
||||
apply_cross_zone_redistribute_css,
|
||||
apply_font_step_compression_css,
|
||||
apply_glue_compression_css,
|
||||
apply_retry_to_layout_css,
|
||||
plan_cross_zone_redistribute,
|
||||
plan_font_step_compression,
|
||||
plan_glue_compression,
|
||||
plan_zone_ratio_retry,
|
||||
)
|
||||
from phase_z2_failure_router import enrich_retry_trace_with_failure_classification
|
||||
from phase_z2_failure_router import (
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
||||
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
||||
@@ -119,6 +128,16 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
|
||||
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
|
||||
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
|
||||
|
||||
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
|
||||
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
|
||||
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
|
||||
IMAGE_ASPECT_DELTA_TOL = 0.05
|
||||
|
||||
# Step 14 table_self_overflow tolerance — scrollW−clientW or scrollH−clientH > TOL ⇒ fail.
|
||||
# Local anchor : IMP-15 실행-2 (Gitea issue #46) — table axis acceptance criteria.
|
||||
# Mirrors existing inline 5px tolerance used by slide/zone/clipped scans in run_overflow_check.
|
||||
TABLE_SCROLL_TOL_PX = 5
|
||||
|
||||
# content_weight 계산 가중치
|
||||
CONTENT_WEIGHT_COEFFS = {
|
||||
"text_per_chars": 800, # text_len / 800 = score
|
||||
@@ -542,7 +561,7 @@ def lookup_v4_match(
|
||||
# (frontend zone-level override / AI-assisted adaptation). Codex #2 conceptual model :
|
||||
# use_as_is → Phase Z direct render
|
||||
# light_edit → deterministic minor adjustment
|
||||
# restructure → AI-assisted frame-aware adaptation (deferred to IMP-31)
|
||||
# restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 — carve-out, AI fallback only, normal path 밖)
|
||||
# reject → design reference only (deferred to IMP-29 frontend override)
|
||||
_IMP05_ROUTE_HINTS: dict[str, str] = {
|
||||
"use_as_is": "direct_render",
|
||||
@@ -953,6 +972,56 @@ def compute_zone_layout_cols(zones_data: list[dict],
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_zone_signals_per_track(
|
||||
preset: dict,
|
||||
zones_data: list[dict],
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
|
||||
|
||||
Each virtual zone aggregates content_weight.score (max) and
|
||||
min_height_px (max) across single-span zones on that track
|
||||
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
|
||||
Falls back to all-span zones (touching every track on the axis)
|
||||
when a track has no single-span zone.
|
||||
"""
|
||||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||||
R = len(rows_grid)
|
||||
C = len(rows_grid[0])
|
||||
|
||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||
for z in zones_data:
|
||||
pos = z["position"]
|
||||
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||||
occ_cols = {
|
||||
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||||
}
|
||||
occupancy.append((z, occ_rows, occ_cols))
|
||||
|
||||
def _track_virtual(idx: int, axis: str) -> dict:
|
||||
if axis == "row":
|
||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||
else:
|
||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||
candidates = single or allspan
|
||||
return {
|
||||
"position": f"_virtual_{axis}_{idx}",
|
||||
"template_id": f"_virtual_{axis}_{idx}",
|
||||
"content_weight": {
|
||||
"score": max(c["content_weight"]["score"] for c in candidates)
|
||||
},
|
||||
"min_height_px": max(
|
||||
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
|
||||
for c in candidates
|
||||
),
|
||||
}
|
||||
|
||||
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
|
||||
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
|
||||
return row_virtuals, col_virtuals
|
||||
|
||||
|
||||
def _compute_per_zone_geometry(
|
||||
layout_css: dict,
|
||||
debug_zones: list[dict],
|
||||
@@ -1083,6 +1152,61 @@ def _build_rows_dynamic(preset: dict, zones_data: list[dict],
|
||||
}
|
||||
|
||||
|
||||
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
|
||||
gap: int = GRID_GAP) -> dict:
|
||||
"""2-D dynamic path — dynamic row heights + dynamic column widths.
|
||||
|
||||
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
|
||||
is neither pure 'rows' nor pure 'cols':
|
||||
- T (top-1-bottom-2)
|
||||
- inverted-T (top-2-bottom-1)
|
||||
- side-T-left (left-1-right-2)
|
||||
- side-T-right (left-2-right-1)
|
||||
- 2x2 (grid-2x2)
|
||||
|
||||
Strategy:
|
||||
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
|
||||
virtual zones (max content_weight.score + max min_height_px of
|
||||
single-span zones, falling back to all-span zones).
|
||||
2) Row virtuals → compute_zone_layout → heights_px (R).
|
||||
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
|
||||
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
|
||||
and dynamic_rows=True, dynamic_cols=True.
|
||||
|
||||
raw_zone_layout carries both solver outputs + the virtual zone lists
|
||||
so step08 trace can explain the per-track aggregation.
|
||||
"""
|
||||
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
|
||||
preset, zones_data
|
||||
)
|
||||
zl_row = compute_zone_layout(row_virtuals, gap=gap)
|
||||
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
|
||||
|
||||
heights_px = zl_row["heights_px"]
|
||||
widths_px = zl_col["widths_px"]
|
||||
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||||
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||||
|
||||
return {
|
||||
"areas": preset["css_areas"],
|
||||
"cols": cols_str,
|
||||
"rows": rows_str,
|
||||
"heights_px": heights_px,
|
||||
"widths_px": widths_px,
|
||||
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
|
||||
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
|
||||
"computation": "2d_dynamic_aggregated",
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": True,
|
||||
"raw_zone_layout": {
|
||||
"row_layout": zl_row,
|
||||
"col_layout": zl_col,
|
||||
"row_virtuals": row_virtuals,
|
||||
"col_virtuals": col_virtuals,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
||||
gap: int = GRID_GAP) -> dict:
|
||||
"""vertical-2 path — dynamic column widths, static fr row heights.
|
||||
@@ -1113,6 +1237,105 @@ def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
||||
}
|
||||
|
||||
|
||||
def _override_to_grid_tracks(
|
||||
preset: dict,
|
||||
zones_data: list[dict],
|
||||
override_zone_geometries: dict[str, dict],
|
||||
gap: int = GRID_GAP,
|
||||
) -> dict:
|
||||
"""2-D override path — derive heights_px (R) + widths_px (C) from
|
||||
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
|
||||
|
||||
IMP-09 PR 2 (B-4) override handler for the five preset families
|
||||
whose topology is neither pure 'rows' nor pure 'cols':
|
||||
- T (top-1-bottom-2)
|
||||
- inverted-T (top-2-bottom-1)
|
||||
- side-T-left (left-1-right-2)
|
||||
- side-T-right (left-2-right-1)
|
||||
- 2x2 (grid-2x2)
|
||||
|
||||
Strategy:
|
||||
1) Parse css_areas into R x C grid.
|
||||
2) For each row r: aggregate h via max over single-row zones
|
||||
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
|
||||
3) For each col c: same with w.
|
||||
4) Normalize per-axis (divide by total) and multiply by avail_*,
|
||||
absorbing rounding diff into the last element.
|
||||
5) If total_h or total_w == 0 (degenerate / empty override),
|
||||
fall back to _build_grid_dynamic_2d default path.
|
||||
"""
|
||||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||||
R = len(rows_grid)
|
||||
C = len(rows_grid[0])
|
||||
|
||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||
for z in zones_data:
|
||||
pos = z["position"]
|
||||
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||||
occ_cols = {
|
||||
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||||
}
|
||||
occupancy.append((z, occ_rows, occ_cols))
|
||||
|
||||
def _track_value(idx: int, axis: str) -> float:
|
||||
if axis == "row":
|
||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||
key = "h"
|
||||
else:
|
||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||
key = "w"
|
||||
candidates = single or allspan
|
||||
vals = [
|
||||
float(override_zone_geometries[z["position"]][key])
|
||||
for z in candidates
|
||||
if z["position"] in override_zone_geometries
|
||||
]
|
||||
return max(vals) if vals else 0.0
|
||||
|
||||
row_values = [_track_value(r, "row") for r in range(R)]
|
||||
col_values = [_track_value(c, "col") for c in range(C)]
|
||||
|
||||
total_h = sum(row_values)
|
||||
total_w = sum(col_values)
|
||||
if total_h == 0 or total_w == 0:
|
||||
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||
|
||||
row_ratios = [v / total_h for v in row_values]
|
||||
col_ratios = [v / total_w for v in col_values]
|
||||
|
||||
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
|
||||
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
|
||||
heights_px = [int(round(r * avail_h)) for r in row_ratios]
|
||||
widths_px = [int(round(r * avail_w)) for r in col_ratios]
|
||||
diff_h = avail_h - sum(heights_px)
|
||||
if diff_h != 0 and heights_px:
|
||||
heights_px[-1] += diff_h
|
||||
diff_w = avail_w - sum(widths_px)
|
||||
if diff_w != 0 and widths_px:
|
||||
widths_px[-1] += diff_w
|
||||
|
||||
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||||
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||||
return {
|
||||
"areas": preset["css_areas"],
|
||||
"cols": cols_str,
|
||||
"rows": rows_str,
|
||||
"heights_px": heights_px,
|
||||
"widths_px": widths_px,
|
||||
"ratios": [round(rr, 3) for rr in row_ratios],
|
||||
"width_ratios": [round(rr, 3) for rr in col_ratios],
|
||||
"computation": "user_override_geometry",
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": True,
|
||||
"raw_zone_layout": {
|
||||
"override_applied": True,
|
||||
"source": override_zone_geometries,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
|
||||
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
||||
|
||||
@@ -1202,21 +1425,29 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
"dynamic_cols": True,
|
||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
||||
}
|
||||
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||||
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
|
||||
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
|
||||
# _build_grid_dynamic_2d inside the helper.
|
||||
return _override_to_grid_tracks(
|
||||
preset, zones_data, override_zone_geometries, gap=gap
|
||||
)
|
||||
else:
|
||||
# PR 1 lock — warn-and-fallthrough preserved.
|
||||
# PR 2 promotes this to strict ValueError via _override_to_grid_tracks.
|
||||
# warn-and-fallthrough preserved for remaining presets (single).
|
||||
# PR 3 territory.
|
||||
print(
|
||||
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
||||
f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.",
|
||||
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# ── Dynamic branch — topology dispatch (PR 1: rows / cols only) ──
|
||||
# ── Dynamic branch — topology dispatch ──
|
||||
if topology == "rows":
|
||||
return _build_rows_dynamic(preset, zones_data, gap)
|
||||
if topology == "cols":
|
||||
return _build_cols_dynamic(preset, zones_data, gap)
|
||||
# PR 2 will dispatch T / inverted-T / side-T-{left,right} / 2x2 here.
|
||||
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||||
return _build_grid_dynamic_2d(preset, zones_data, gap)
|
||||
# PR 3 will dispatch single here.
|
||||
return _build_fr_default(preset)
|
||||
|
||||
@@ -1713,14 +1944,110 @@ def _attempt_zone_ratio_retry(
|
||||
return base_trace
|
||||
|
||||
|
||||
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
|
||||
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
|
||||
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
|
||||
_SALVAGE_FAIL_BY_ACTION = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
}
|
||||
|
||||
|
||||
def _attempt_salvage_chain(
|
||||
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||||
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
|
||||
) -> dict:
|
||||
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
|
||||
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
|
||||
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
|
||||
Honors IMP-09 dynamic_cols / fr_default gate.
|
||||
"""
|
||||
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
|
||||
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
|
||||
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
|
||||
return trace
|
||||
trace["salvage_attempted"] = True
|
||||
failure_type = initial_failure_type
|
||||
ci = cascade_inputs
|
||||
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
|
||||
routing = route_retry_failure(failure_type) or {}
|
||||
next_action = routing.get("next_proposed_action")
|
||||
if next_action not in _SALVAGE_FAIL_BY_ACTION:
|
||||
trace["salvage_terminal_action"] = next_action
|
||||
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||||
return trace
|
||||
if next_action == "cross_zone_redistribute":
|
||||
if ci.get("fit_analysis") is None:
|
||||
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||||
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
|
||||
else:
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
|
||||
min_margin_px=ci.get("min_margin_px"))
|
||||
apply_fn = apply_cross_zone_redistribute_css
|
||||
elif next_action == "glue_compression":
|
||||
plan = plan_glue_compression(
|
||||
excess_px=float(ci.get("excess_px") or 0.0),
|
||||
block_count=int(ci.get("block_count") or 0),
|
||||
zone_position=str(ci.get("zone_position") or ""))
|
||||
apply_fn = apply_glue_compression_css
|
||||
else:
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||||
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
|
||||
available_lines=int(ci.get("available_lines") or 0),
|
||||
chars_per_line=int(ci.get("chars_per_line") or 0),
|
||||
zone_position=str(ci.get("zone_position") or ""))
|
||||
apply_fn = apply_font_step_compression_css
|
||||
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
|
||||
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||||
candidate_html, candidate_overflow, passed = None, None, False
|
||||
if css_override:
|
||||
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
|
||||
style = f"<style>\n{css_override}\n</style>"
|
||||
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
|
||||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||||
candidate_overflow = run_overflow_check(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
step = {"action": next_action, "plan": plan, "passed": passed,
|
||||
"css_override": css_override or None,
|
||||
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
|
||||
if passed:
|
||||
out_path.write_text(candidate_html, encoding="utf-8")
|
||||
step["post_salvage_overflow"] = candidate_overflow
|
||||
trace["salvage_steps"].append(step)
|
||||
trace["salvage_passed"] = True
|
||||
return trace
|
||||
step["failure_reason"] = (
|
||||
(plan.get("failure_reason") if isinstance(plan, dict) else None)
|
||||
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
|
||||
or "infeasible or no CSS emitted")
|
||||
trace["salvage_steps"].append(step)
|
||||
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||||
return trace
|
||||
|
||||
|
||||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str,
|
||||
layout_css: dict, gap_px: int = GRID_GAP) -> str:
|
||||
layout_css: dict, gap_px: int = GRID_GAP,
|
||||
*, embedded_mode: str = "auto") -> str:
|
||||
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
|
||||
|
||||
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
|
||||
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
|
||||
|
||||
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
|
||||
slide_base.html body CSS contract. Default "auto" preserves backward-compat
|
||||
with run_overflow_check standalone path and lets iframe consumers signal via
|
||||
?embedded=1 or window.self!==window.top.
|
||||
"""
|
||||
if embedded_mode not in ("auto", "embedded", "standalone"):
|
||||
raise ValueError(
|
||||
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
|
||||
"expected one of 'auto', 'embedded', 'standalone'"
|
||||
)
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
@@ -1744,6 +2071,7 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
layout_css=layout_css,
|
||||
gap_px=gap_px,
|
||||
token_css=_read_token_css(),
|
||||
embedded_mode=embedded_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -1808,6 +2136,14 @@ def run_overflow_check(html_path: Path) -> dict:
|
||||
|
||||
const zones = [];
|
||||
const zone_geometries_px = [];
|
||||
|
||||
// IMP-15 실행-2 (issue #46) — element-identity dedup map for table_events.
|
||||
// Map<Element, integer> keyed by DOM node reference (NOT class string) so that
|
||||
// two wrappers sharing identical className resolve to distinct map entries.
|
||||
// Populated alongside the existing per-zone clipped_inner scan below.
|
||||
const clippedWrapperMap = new Map();
|
||||
let clippedIdxCounter = 0;
|
||||
|
||||
slide.querySelectorAll('.zone').forEach((z) => {
|
||||
const pos = z.getAttribute('data-zone-position') || 'unknown';
|
||||
const tid = z.getAttribute('data-template-id') || '?';
|
||||
@@ -1855,6 +2191,13 @@ def run_overflow_check(html_path: Path) -> dict:
|
||||
scrollWidth: el.scrollWidth,
|
||||
scrollHeight: el.scrollHeight,
|
||||
});
|
||||
// IMP-15 실행-2 (issue #46) — element-identity registration.
|
||||
// Key by DOM node `el`, NOT className: two wrappers with identical
|
||||
// class string still hash to distinct Map entries.
|
||||
if (!clippedWrapperMap.has(el)) {
|
||||
clippedWrapperMap.set(el, clippedIdxCounter);
|
||||
clippedIdxCounter++;
|
||||
}
|
||||
}
|
||||
});
|
||||
m.clipped_inner = clipped;
|
||||
@@ -1890,7 +2233,98 @@ def run_overflow_check(html_path: Path) -> dict:
|
||||
});
|
||||
});
|
||||
|
||||
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };
|
||||
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
|
||||
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
|
||||
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
|
||||
const image_events = [];
|
||||
slide.querySelectorAll('img').forEach((img) => {
|
||||
const parentZone = img.closest('.zone');
|
||||
const zonePos = parentZone
|
||||
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||||
: 'unknown';
|
||||
const zoneTid = parentZone
|
||||
? (parentZone.getAttribute('data-template-id') || '?')
|
||||
: '?';
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const rendered_w = imgRect.width;
|
||||
const rendered_h = imgRect.height;
|
||||
const natural_w = img.naturalWidth;
|
||||
const natural_h = img.naturalHeight;
|
||||
const natural_ratio = (natural_w > 0 && natural_h > 0)
|
||||
? (natural_w / natural_h)
|
||||
: null;
|
||||
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
|
||||
? (rendered_w / rendered_h)
|
||||
: null;
|
||||
const delta = (natural_ratio !== null && rendered_ratio !== null)
|
||||
? (rendered_ratio - natural_ratio)
|
||||
: null;
|
||||
image_events.push({
|
||||
src: img.getAttribute('src') || '',
|
||||
zone_position: zonePos,
|
||||
zone_template_id: zoneTid,
|
||||
natural_w: natural_w,
|
||||
natural_h: natural_h,
|
||||
rendered_w: Math.round(rendered_w),
|
||||
rendered_h: Math.round(rendered_h),
|
||||
natural_ratio: natural_ratio,
|
||||
rendered_ratio: rendered_ratio,
|
||||
delta: delta,
|
||||
bbox: {
|
||||
x: Math.round(imgRect.left - slideRect.left),
|
||||
y: Math.round(imgRect.top - slideRect.top),
|
||||
w: Math.round(rendered_w),
|
||||
h: Math.round(rendered_h),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// IMP-15 실행-2 (issue #46) — table_events[] for table_self_overflow detection.
|
||||
// One entry per <table> under .slide. wrapper_clipped_index is the integer index
|
||||
// (from clippedWrapperMap) of the nearest ancestor that is itself in the clipped
|
||||
// wrapper set, or null. Element-identity walk (NOT className) so that two same-class
|
||||
// wrappers (W1 clipped, W2 not) resolve independently for any contained <table>.
|
||||
const table_events = [];
|
||||
slide.querySelectorAll('table').forEach((tbl) => {
|
||||
const parentZone = tbl.closest('.zone');
|
||||
const zonePos = parentZone
|
||||
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||||
: 'unknown';
|
||||
const zoneTid = parentZone
|
||||
? (parentZone.getAttribute('data-template-id') || '?')
|
||||
: '?';
|
||||
let wrapper_clipped_index = null;
|
||||
let node = tbl.parentElement;
|
||||
while (node && node !== slide) {
|
||||
if (clippedWrapperMap.has(node)) {
|
||||
wrapper_clipped_index = clippedWrapperMap.get(node);
|
||||
break;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
const tblRect = tbl.getBoundingClientRect();
|
||||
const dx = tbl.scrollWidth - tbl.clientWidth;
|
||||
const dy = tbl.scrollHeight - tbl.clientHeight;
|
||||
table_events.push({
|
||||
zone_position: zonePos,
|
||||
zone_template_id: zoneTid,
|
||||
clientWidth: tbl.clientWidth,
|
||||
clientHeight: tbl.clientHeight,
|
||||
scrollWidth: tbl.scrollWidth,
|
||||
scrollHeight: tbl.scrollHeight,
|
||||
excess_x: Math.max(0, dx),
|
||||
excess_y: Math.max(0, dy),
|
||||
wrapper_clipped_index: wrapper_clipped_index,
|
||||
bbox: {
|
||||
x: Math.round(tblRect.left - slideRect.left),
|
||||
y: Math.round(tblRect.top - slideRect.top),
|
||||
w: Math.round(tblRect.width),
|
||||
h: Math.round(tblRect.height),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events, table_events };
|
||||
""")
|
||||
|
||||
screenshot_path = html_path.parent / "preview.png"
|
||||
@@ -1930,6 +2364,46 @@ def run_overflow_check(html_path: Path) -> dict:
|
||||
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
|
||||
)
|
||||
|
||||
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
|
||||
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
|
||||
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
|
||||
for ev in result.get("image_events", []):
|
||||
delta = ev.get("delta")
|
||||
if delta is None:
|
||||
continue
|
||||
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
|
||||
n_ratio = ev.get("natural_ratio")
|
||||
r_ratio = ev.get("rendered_ratio")
|
||||
src = ev.get("src", "")
|
||||
pos = ev.get("zone_position", "unknown")
|
||||
tid = ev.get("zone_template_id", "?")
|
||||
fail_reasons.append(
|
||||
f"image aspect mismatch in zone--{pos}: "
|
||||
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
|
||||
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
|
||||
)
|
||||
|
||||
# IMP-15 실행-2 (issue #46) — table_self_overflow aggregation.
|
||||
# Emit fail_reason only when (excess_x>TOL OR excess_y>TOL) AND wrapper_clipped_index is None.
|
||||
# The clipped-wrapper case is already accounted for by the clipped_inner fail_reason above;
|
||||
# element-identity dedup (clippedWrapperMap keyed by DOM node ref, NOT className) prevents
|
||||
# double-counting and—critically—prevents two same-class wrappers from masking each other.
|
||||
for ev in result.get("table_events", []):
|
||||
if ev.get("wrapper_clipped_index") is not None:
|
||||
continue
|
||||
excess_x = ev.get("excess_x", 0) or 0
|
||||
excess_y = ev.get("excess_y", 0) or 0
|
||||
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
|
||||
pos = ev.get("zone_position", "unknown")
|
||||
tid = ev.get("zone_template_id", "?")
|
||||
fail_reasons.append(
|
||||
f"table self-overflow in zone--{pos}: "
|
||||
f"excess {excess_y}px vert / {excess_x}px horiz "
|
||||
f"(content {ev.get('scrollWidth')}x{ev.get('scrollHeight')} vs "
|
||||
f"container {ev.get('clientWidth')}x{ev.get('clientHeight')}, "
|
||||
f"template={tid}, tol={TABLE_SCROLL_TOL_PX})"
|
||||
)
|
||||
|
||||
result["passed"] = len(fail_reasons) == 0
|
||||
result["fail_reasons"] = fail_reasons
|
||||
return result
|
||||
@@ -2263,6 +2737,9 @@ def write_debug_json(run_dir: Path, layout_preset: str,
|
||||
"visual_runtime_check": visual_runtime_check,
|
||||
# A-6 (IMP-01 #1) — additive top-level zone bbox trace (slide-relative px)
|
||||
"zone_geometries_px": (visual_runtime_check or {}).get("zone_geometries_px", []),
|
||||
# IMP-15 실행-4 (issue #48) — additive top-level Step 14 event streams
|
||||
"image_events": (visual_runtime_check or {}).get("image_events", []),
|
||||
"table_events": (visual_runtime_check or {}).get("table_events", []),
|
||||
}
|
||||
debug_path = run_dir / "debug.json"
|
||||
debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -3383,7 +3860,14 @@ def run_phase_z2_mvp1(
|
||||
dz["ratio"] = geo["zone_height_ratio"]
|
||||
dz["width_px"] = geo["zone_width_px"]
|
||||
dz["width_ratio"] = geo["zone_width_ratio"]
|
||||
if layout_css["dynamic_rows"]:
|
||||
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
|
||||
print(
|
||||
f" zones : 2-D heights {layout_css['heights_px']} px, "
|
||||
f"widths {layout_css['widths_px']} px, "
|
||||
f"ratios {layout_css['ratios']}, "
|
||||
f"width_ratios {layout_css['width_ratios']}"
|
||||
)
|
||||
elif layout_css["dynamic_rows"]:
|
||||
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
|
||||
elif layout_css.get("dynamic_cols"):
|
||||
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
|
||||
@@ -3750,6 +4234,24 @@ def run_phase_z2_mvp1(
|
||||
"delegated_to": delegated,
|
||||
})
|
||||
|
||||
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
|
||||
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
|
||||
# None when contract unregistered (frontend tolerates undefined).
|
||||
# Single get_contract lookup binds both catalog_registered and min_height_px.
|
||||
v4_all_judgments_list = []
|
||||
for c in v4_all_for_unit:
|
||||
_contract = get_contract(c.template_id)
|
||||
v4_all_judgments_list.append({
|
||||
"template_id": c.template_id,
|
||||
"frame_id": c.frame_id,
|
||||
"frame_number": c.frame_number,
|
||||
"v4_rank": c.v4_rank,
|
||||
"confidence": c.confidence,
|
||||
"label": c.label,
|
||||
"catalog_registered": _contract is not None,
|
||||
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
|
||||
})
|
||||
|
||||
application_plan_units.append({
|
||||
"unit_id": unit_id,
|
||||
"layout_preset": layout_preset,
|
||||
@@ -3785,18 +4287,8 @@ def run_phase_z2_mvp1(
|
||||
# v4_all_judgments 는 reject 포함.
|
||||
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
||||
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
||||
"v4_all_judgments": [
|
||||
{
|
||||
"template_id": c.template_id,
|
||||
"frame_id": c.frame_id,
|
||||
"frame_number": c.frame_number,
|
||||
"v4_rank": c.v4_rank,
|
||||
"confidence": c.confidence,
|
||||
"label": c.label,
|
||||
"catalog_registered": get_contract(c.template_id) is not None,
|
||||
}
|
||||
for c in v4_all_for_unit
|
||||
],
|
||||
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
|
||||
"v4_all_judgments": v4_all_judgments_list,
|
||||
"application_candidates": app_candidates,
|
||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
||||
# additive fields. None / False / [] when no override CLI used.
|
||||
@@ -4022,7 +4514,8 @@ def run_phase_z2_mvp1(
|
||||
note=(
|
||||
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
|
||||
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
|
||||
"image / table 검사 부재 — Step 14 ⚠ partial."
|
||||
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
|
||||
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4099,19 +4592,85 @@ def run_phase_z2_mvp1(
|
||||
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
|
||||
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
|
||||
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
|
||||
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
|
||||
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
|
||||
_plan = retry_trace.get("plan") or {}
|
||||
_tpos = _plan.get("target_zone_position")
|
||||
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
|
||||
_excess = float(_plan.get("target_excess_y") or 0.0)
|
||||
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
|
||||
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
|
||||
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
|
||||
# Each position becomes a "role"; all share conceptual zone "slide_body" so
|
||||
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
|
||||
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||||
_fa_roles, _fa_containers = {}, {}
|
||||
for _dz in debug_zones:
|
||||
_pos = _dz.get("position")
|
||||
if not _pos:
|
||||
continue
|
||||
_alloc = float(_dz.get("height_px") or 0.0)
|
||||
_zm = _zof.get(_pos) or {}
|
||||
_ch = float(_zm.get("clientHeight") or _alloc)
|
||||
_sh = float(_zm.get("scrollHeight") or _ch)
|
||||
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
|
||||
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
|
||||
_salvage_trace = _attempt_salvage_chain(
|
||||
run_dir=run_dir, out_path=out_path,
|
||||
slide_title=slide_title, slide_footer=slide_footer,
|
||||
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
|
||||
cascade_inputs={
|
||||
"fit_analysis": FitAnalysis(roles=_fa_roles),
|
||||
"containers": _fa_containers,
|
||||
"min_margin_px": None,
|
||||
"excess_px": _excess, "excess_after_glue_px": _excess,
|
||||
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
|
||||
"zone_position": _tpos or "",
|
||||
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
|
||||
"available_lines": int(_tdz.get("available_lines") or 0),
|
||||
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
|
||||
},
|
||||
initial_failure_type=_ft, gap_px=GRID_GAP,
|
||||
)
|
||||
retry_trace.update(_salvage_trace)
|
||||
if _salvage_trace.get("salvage_passed"):
|
||||
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
|
||||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||||
router_decision = route_fit_classification(fit_classification)
|
||||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||||
router_decision["frame_reselect_fallback_status"] = (
|
||||
"pre_render_rank_2_3_fallback_implemented; "
|
||||
"post_render visual-fail rerender remains routed through existing action trace"
|
||||
)
|
||||
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
|
||||
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
|
||||
# state. classify_retry_failure short-circuits on salvage_passed=True → both
|
||||
# fields become None (no failure to classify, no escalation pending).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# ─── Step 17: Implemented Action (retry) ───
|
||||
_write_step_artifact(
|
||||
run_dir, 17, "retry_trace",
|
||||
data=retry_trace,
|
||||
step_status=(
|
||||
"failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||
else "done" if retry_trace.get("retry_passed")
|
||||
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||||
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||
else "skipped"
|
||||
),
|
||||
pipeline_path_connected=True,
|
||||
inputs=["step16_router_decision.json"],
|
||||
outputs=["step17_retry_trace.json"],
|
||||
note="A3 — zone_ratio_retry action only. 다른 actions (layout_adjust / frame_internal_fit 등) 미구현 — Step 17 ⚠ partial.",
|
||||
note=(
|
||||
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||||
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
||||
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
|
||||
),
|
||||
)
|
||||
|
||||
# ─── Step 18: Failure Classification (A4-1) ───
|
||||
|
||||
+207
-13
@@ -162,35 +162,55 @@ def plan_zone_ratio_retry(
|
||||
),
|
||||
}
|
||||
|
||||
# A3 minimal : single primary donor (multi-donor 는 future)
|
||||
primary_donor = donor_candidates[0]
|
||||
if primary_donor["slack"] < target_added_px:
|
||||
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
||||
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
||||
if aggregate_slack_available < target_added_px:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_max_slack": primary_donor["slack"],
|
||||
"donor_zone_position": donor_candidates[0]["position"],
|
||||
"donor_max_slack": donor_candidates[0]["slack"],
|
||||
"donor_reduced_px": 0,
|
||||
"donors_used": [],
|
||||
"aggregate_slack_used": 0,
|
||||
"aggregate_slack_available": aggregate_slack_available,
|
||||
"zones_after": dict(zones_before),
|
||||
"failure_reason": (
|
||||
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
|
||||
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
|
||||
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
|
||||
f"primary donor '{donor_candidates[0]['position']}' slack "
|
||||
f"{donor_candidates[0]['slack']}px (aggregate "
|
||||
f"{aggregate_slack_available}px across {len(donor_candidates)} "
|
||||
f"candidate(s)) < target_added_px {target_added_px}px "
|
||||
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
|
||||
),
|
||||
}
|
||||
|
||||
# feasible
|
||||
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
|
||||
zones_after = dict(zones_before)
|
||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||
zones_after[primary_donor["position"]] = (
|
||||
zones_before[primary_donor["position"]] - target_added_px
|
||||
)
|
||||
donors_used: list[dict] = []
|
||||
remaining = target_added_px
|
||||
for donor in donor_candidates:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(donor["slack"], remaining)
|
||||
zones_after[donor["position"]] = zones_before[donor["position"]] - take
|
||||
donors_used.append({
|
||||
"position": donor["position"],
|
||||
"reduced_px": take,
|
||||
"slack_before": donor["slack"],
|
||||
"slack_after": donor["slack"] - take,
|
||||
})
|
||||
remaining -= take
|
||||
|
||||
primary_donor = donors_used[0]
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": True,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_reduced_px": target_added_px,
|
||||
"donor_reduced_px": primary_donor["reduced_px"],
|
||||
"donors_used": donors_used,
|
||||
"aggregate_slack_used": target_added_px,
|
||||
"aggregate_slack_available": aggregate_slack_available,
|
||||
"zones_after": zones_after,
|
||||
}
|
||||
|
||||
@@ -213,3 +233,177 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
|
||||
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
||||
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
||||
return new_layout_css
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
|
||||
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
|
||||
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
|
||||
# can drive it deterministically. Plan-only — no rerender / no final.html
|
||||
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_cross_zone_redistribute(
|
||||
*,
|
||||
fit_analysis,
|
||||
containers: dict,
|
||||
min_margin_px: float | None = None,
|
||||
) -> dict:
|
||||
"""Cross-zone (intra-zone role-to-role) redistribute plan.
|
||||
|
||||
Plan-only — no rerender / no final.html mutation. Side-effect-free
|
||||
(operates on deepcopy of fit_analysis).
|
||||
"""
|
||||
from copy import deepcopy
|
||||
from src.fit_verifier import redistribute as _fv_redistribute
|
||||
|
||||
role_heights_before = {
|
||||
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
|
||||
}
|
||||
base_plan = {
|
||||
"action": "cross_zone_redistribute",
|
||||
"role_heights_before": role_heights_before,
|
||||
}
|
||||
if not role_heights_before:
|
||||
return {**base_plan, "feasible": False, "role_heights_after": {},
|
||||
"can_redistribute": False,
|
||||
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
|
||||
|
||||
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
|
||||
redistribution = dict(result.redistribution or {})
|
||||
can_redistribute = bool(result.can_redistribute)
|
||||
|
||||
if not can_redistribute or not redistribution:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"role_heights_after": redistribution or dict(role_heights_before),
|
||||
"can_redistribute": can_redistribute,
|
||||
"failure_reason": (
|
||||
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
|
||||
"or surplus insufficient to cover deficit within envelope."
|
||||
),
|
||||
}
|
||||
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
|
||||
"can_redistribute": True}
|
||||
|
||||
|
||||
def apply_cross_zone_redistribute_css(plan: dict) -> str:
|
||||
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
|
||||
|
||||
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
role_heights_after = plan.get("role_heights_after") or {}
|
||||
role_heights_before = plan.get("role_heights_before") or {}
|
||||
rules: list[str] = []
|
||||
for role, new_height in role_heights_after.items():
|
||||
before = role_heights_before.get(role)
|
||||
if before is None or abs(float(before) - float(new_height)) < 0.5:
|
||||
continue
|
||||
new_h_int = int(round(float(new_height)))
|
||||
rules.append(
|
||||
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
|
||||
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
|
||||
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
|
||||
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||
|
||||
|
||||
def plan_glue_compression(
|
||||
*, excess_px: float, block_count: int, zone_position: str,
|
||||
) -> dict:
|
||||
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
|
||||
from src.space_allocator import (
|
||||
calculate_glue_absorption, compute_glue_css_overrides,
|
||||
)
|
||||
base = {"action": "glue_compression", "zone_position": zone_position,
|
||||
"excess_px": float(excess_px), "block_count": int(block_count)}
|
||||
if excess_px <= 0:
|
||||
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
|
||||
"failure_reason": "excess_px <= 0 — no compression needed."}
|
||||
absorption_max = float(calculate_glue_absorption(block_count))
|
||||
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
|
||||
if excess_px > absorption_max:
|
||||
return {**base, "feasible": False, "overrides": overrides,
|
||||
"absorption_max_px": absorption_max,
|
||||
"failure_reason": (
|
||||
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
|
||||
f"max absorption {absorption_max:.1f}px "
|
||||
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
|
||||
)}
|
||||
return {**base, "feasible": True, "overrides": overrides,
|
||||
"absorption_max_px": absorption_max}
|
||||
|
||||
|
||||
def apply_glue_compression_css(plan: dict) -> str:
|
||||
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
zone_position = plan.get("zone_position")
|
||||
overrides = plan.get("overrides") or {}
|
||||
if not zone_position or not overrides:
|
||||
return ""
|
||||
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
|
||||
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
|
||||
|
||||
|
||||
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
|
||||
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
|
||||
|
||||
|
||||
def plan_font_step_compression(
|
||||
*, current_font_px: float, excess_after_glue_px: float,
|
||||
available_lines: int, chars_per_line: int, zone_position: str,
|
||||
) -> dict:
|
||||
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
|
||||
contains a size whose line-height savings cover excess_after_glue_px. Missing
|
||||
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
|
||||
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
|
||||
floor = float(FONT_SIZE_STEPS[-1])
|
||||
base = {"action": "font_step_compression", "zone_position": zone_position,
|
||||
"current_font_px": float(current_font_px),
|
||||
"excess_after_glue_px": float(excess_after_glue_px),
|
||||
"available_lines": int(available_lines or 0),
|
||||
"chars_per_line": int(chars_per_line or 0),
|
||||
"font_floor_px": floor}
|
||||
if excess_after_glue_px <= 0:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
|
||||
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
|
||||
if current_font_px <= floor:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": (
|
||||
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
|
||||
target = find_fitting_font_size(
|
||||
current_font_px=float(current_font_px),
|
||||
excess_after_glue_px=float(excess_after_glue_px),
|
||||
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
|
||||
if target is None:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": (
|
||||
f"font_step floor — {floor:.1f}px cannot absorb "
|
||||
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
|
||||
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
|
||||
return {**base, "feasible": True, "target_font_px": float(target)}
|
||||
|
||||
|
||||
def apply_font_step_compression_css(plan: dict) -> str:
|
||||
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
zone_position = plan.get("zone_position")
|
||||
target_font_px = plan.get("target_font_px")
|
||||
if not zone_position or target_font_px is None:
|
||||
return ""
|
||||
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||
|
||||
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||
@@ -65,6 +65,12 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
||||
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
||||
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
|
||||
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
|
||||
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Phase Z2 deterministic verification utilities (IMP-16-U1 port).
|
||||
|
||||
Ports the H3 deterministic subset of src/content_verifier.py into a
|
||||
Phase Z-owned module so the Phase Z pipeline never imports the Phase Q
|
||||
reference-only module (which co-hosts H4/H5 Kei/AI assets).
|
||||
|
||||
Scope: deterministic, pure, no I/O, no LLM call, no httpx/SSE.
|
||||
Wiring into Step 1/2/14/21/22 is gated behind IMP-07 (see
|
||||
docs/architecture/IMP-16-U2-WIRING-DESIGN.md when u11 lands).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerificationResult:
|
||||
"""Single-axis deterministic verification outcome.
|
||||
|
||||
Mirrors the Phase Q VerificationResult shape so callers ported from
|
||||
that surface keep their field access; the value semantics are
|
||||
Phase Z-owned (no Phase Q area defaults baked in).
|
||||
"""
|
||||
|
||||
passed: bool
|
||||
area_name: str
|
||||
checks: dict[str, bool] = field(default_factory=dict)
|
||||
score: float = 0.0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
"""Extract visible text only. Skips <style> and <script> bodies.
|
||||
|
||||
Pure stdlib (html.parser). Whitespace-only data chunks are dropped;
|
||||
surviving chunks are stripped before appending to preserve token
|
||||
boundaries for downstream normalization / keyword logic.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.texts: list[str] = []
|
||||
self._skip = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self._skip:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self.texts.append(stripped)
|
||||
|
||||
|
||||
def extract_text_from_html(html: str) -> list[str]:
|
||||
"""Return ordered list of visible text fragments from an HTML string.
|
||||
|
||||
Deterministic, pure: no I/O, no LLM, no network. Used by Phase Z
|
||||
verification to compare reverse-path HTML against MDX text without
|
||||
importing the Phase Q reference-only module.
|
||||
"""
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
return parser.texts
|
||||
|
||||
|
||||
_PARTICLES: list[str] = sorted(
|
||||
["에서", "으로", "부터", "까지", "에게", "한테",
|
||||
"은", "는", "이", "가", "을", "를", "에", "의",
|
||||
"로", "와", "과", "도", "만", "께"],
|
||||
key=len, reverse=True,
|
||||
)
|
||||
|
||||
_ENDING_NORMALIZE: dict[str, str] = {
|
||||
"있음": "있다", "됨": "된다", "함": "한다", "임": "이다",
|
||||
"없음": "없다", "았음": "았다", "었음": "었다",
|
||||
}
|
||||
|
||||
|
||||
def normalize_for_comparison(text: str) -> str:
|
||||
"""Normalize text for deterministic comparison (Phase Z H3 port).
|
||||
|
||||
Steps (order matters): collapse whitespace, strip bullet markers,
|
||||
decode the small HTML-entity set used by the reverse path, then
|
||||
fold a single trailing 개조식 ending to its 서술형 form.
|
||||
"""
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = re.sub(r"[•◦·\-▪▸►]", "", text).strip()
|
||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace(" ", " ").replace("'", "'").replace(""", '"')
|
||||
for gaejo, seosul in _ENDING_NORMALIZE.items():
|
||||
if text.endswith(gaejo):
|
||||
text = text[: -len(gaejo)] + seosul
|
||||
break
|
||||
return text
|
||||
|
||||
|
||||
def extract_keywords(text: str) -> list[str]:
|
||||
"""Extract length>=3 tokens, then strip a trailing Korean particle.
|
||||
|
||||
Deterministic, pure: tokenises on the Phase Z H3 character class
|
||||
``[가-힣a-zA-Z0-9()]+``, drops tokens shorter than 3 characters,
|
||||
and folds a single longest-match trailing particle from
|
||||
``_PARTICLES`` when the remaining stem is still length >= 2.
|
||||
"""
|
||||
words = re.findall(r"[가-힣a-zA-Z0-9()]+", text)
|
||||
keywords: list[str] = []
|
||||
for w in words:
|
||||
if len(w) < 3:
|
||||
continue
|
||||
for p in _PARTICLES:
|
||||
if w.endswith(p) and len(w) - len(p) >= 2:
|
||||
w = w[: -len(p)]
|
||||
break
|
||||
if len(w) >= 2:
|
||||
keywords.append(w)
|
||||
return keywords
|
||||
|
||||
|
||||
_META_PREFIXES: list[str] = [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
|
||||
_META_INLINE_FRAGMENTS: tuple[str, ...] = (
|
||||
"현상-문제 인과관계",
|
||||
"상위-하위 포함 관계",
|
||||
"독립적 나열",
|
||||
)
|
||||
|
||||
|
||||
def strip_meta_lines(text: str) -> str:
|
||||
"""Drop Kei prompt meta/instruction lines before verification.
|
||||
|
||||
A line is dropped if its stripped form starts with any prefix in
|
||||
``_META_PREFIXES`` (e.g. ``제목 라벨:``) or contains any inline
|
||||
expression-hint fragment in ``_META_INLINE_FRAGMENTS`` (e.g.
|
||||
``현상-문제 인과관계``). These are prompt directives, not slide
|
||||
content; they must not enter sentence/keyword extraction for the
|
||||
B-2 reverse path. Deterministic, pure: no I/O, no LLM, no regex
|
||||
against runtime data.
|
||||
"""
|
||||
filtered: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if any(stripped.startswith(prefix) for prefix in _META_PREFIXES):
|
||||
continue
|
||||
if any(fragment in stripped for fragment in _META_INLINE_FRAGMENTS):
|
||||
continue
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
_BULLET_MARKER_PATTERN = re.compile(r"^[\-•◦·\d]+[.)]\s*")
|
||||
_SENTENCE_SPLIT_PATTERN = re.compile(r"(?<=\.)\s+")
|
||||
_MIN_SENTENCE_LEN = 5
|
||||
|
||||
|
||||
def split_into_sentences(text: str) -> list[str]:
|
||||
"""Split text into sentences for deterministic comparison.
|
||||
|
||||
Pipeline (order matters): drop Kei meta/instruction lines via
|
||||
``strip_meta_lines``, split on newline, skip empties and ``#``-led
|
||||
header lines, strip any leading bullet/numeric marker matching
|
||||
``_BULLET_MARKER_PATTERN``, then split on inter-sentence whitespace
|
||||
following a period. Parts shorter than ``_MIN_SENTENCE_LEN`` are
|
||||
dropped so single-token noise (e.g. residual punctuation) cannot
|
||||
enter the preservation/invented-text checks.
|
||||
"""
|
||||
text = strip_meta_lines(text)
|
||||
sentences: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = _BULLET_MARKER_PATTERN.sub("", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
for part in _SENTENCE_SPLIT_PATTERN.split(line):
|
||||
part = part.strip()
|
||||
if len(part) >= _MIN_SENTENCE_LEN:
|
||||
sentences.append(part)
|
||||
return sentences
|
||||
|
||||
|
||||
_SENTENCE_KEYWORD_MATCH_THRESHOLD = 0.6
|
||||
_SENTENCE_SEQUENCE_MATCH_THRESHOLD = 0.65
|
||||
|
||||
|
||||
def _sentence_matches_html(
|
||||
sentence: str,
|
||||
html_combined: str,
|
||||
html_texts: list[str],
|
||||
) -> bool:
|
||||
"""Return True if ``sentence`` is preserved in the HTML side.
|
||||
|
||||
Two-axis match: a keyword-ratio gate against ``html_combined`` (the
|
||||
pre-normalized join of all visible HTML text fragments) and a
|
||||
SequenceMatcher fallback against each individual normalized HTML
|
||||
fragment. A sentence whose keyword set is empty after normalization
|
||||
is treated as preserved (no falsifiable signal). Pure helper used
|
||||
by ``verify_text_preservation`` (u8); thresholds are lifted to
|
||||
named module constants so the surface is auditable.
|
||||
"""
|
||||
norm_orig = normalize_for_comparison(sentence)
|
||||
keywords = extract_keywords(norm_orig)
|
||||
if not keywords:
|
||||
return True
|
||||
kw_found = sum(1 for kw in keywords if kw in html_combined)
|
||||
kw_ratio = kw_found / len(keywords)
|
||||
best_ratio = 0.0
|
||||
for html_text in html_texts:
|
||||
norm_html = normalize_for_comparison(html_text)
|
||||
ratio = SequenceMatcher(None, norm_orig, norm_html).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
return (
|
||||
kw_ratio >= _SENTENCE_KEYWORD_MATCH_THRESHOLD
|
||||
or best_ratio >= _SENTENCE_SEQUENCE_MATCH_THRESHOLD
|
||||
)
|
||||
|
||||
|
||||
_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70
|
||||
_MISSING_SENTENCE_REPORT_LIMIT = 5
|
||||
_MISSING_SENTENCE_TRUNCATE_LEN = 60
|
||||
|
||||
|
||||
def verify_text_preservation(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
threshold: float = _TEXT_PRESERVATION_DEFAULT_THRESHOLD,
|
||||
) -> VerificationResult:
|
||||
"""Verify the original MDX text is preserved in the generated HTML.
|
||||
|
||||
Splits MDX via u6, pre-normalizes joined HTML via u2+u3, then per
|
||||
sentence delegates to u7. Empty sentence list -> passed True,
|
||||
score 1.0. Missing sentences are capped at the report limit and
|
||||
each truncated to the truncate length constant.
|
||||
"""
|
||||
original_sentences = split_into_sentences(original_mdx)
|
||||
if not original_sentences:
|
||||
return VerificationResult(passed=True, area_name=area_name,
|
||||
checks={"text_preservation": True}, score=1.0)
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
html_combined = normalize_for_comparison(" ".join(html_texts))
|
||||
matched = 0
|
||||
missing: list[str] = []
|
||||
for sentence in original_sentences:
|
||||
if _sentence_matches_html(sentence, html_combined, html_texts):
|
||||
matched += 1
|
||||
else:
|
||||
missing.append(sentence)
|
||||
score = matched / len(original_sentences)
|
||||
passed = score >= threshold
|
||||
errors: list[str] = []
|
||||
if not passed:
|
||||
errors = [f"누락 문장 ({len(missing)}/{len(original_sentences)}):"]
|
||||
for s in missing[:_MISSING_SENTENCE_REPORT_LIMIT]:
|
||||
errors.append(
|
||||
f" - \"{s[:_MISSING_SENTENCE_TRUNCATE_LEN]}...\""
|
||||
if len(s) > _MISSING_SENTENCE_TRUNCATE_LEN else f" - \"{s}\""
|
||||
)
|
||||
warnings = ([f"보존율: {score:.0%} ({matched}/{len(original_sentences)} 문장)"]
|
||||
if score < 1.0 else [])
|
||||
return VerificationResult(
|
||||
passed=passed, area_name=area_name,
|
||||
checks={"text_preservation": passed}, score=score,
|
||||
errors=errors, warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
_INVENTED_TEXT_MIN_LENGTH = 15
|
||||
_INVENTED_TEXT_ALLOWED_LABELS: frozenset[str] = frozenset({
|
||||
"용어 정의", "핵심 메시지", "상세 비교",
|
||||
})
|
||||
_INVENTED_TEXT_CSS_NUMBER_PATTERN = re.compile(r"^[\d\s.,%px#rgb()]+$")
|
||||
_INVENTED_TEXT_KEYWORD_THRESHOLD = 0.4
|
||||
_INVENTED_TEXT_TRUNCATE_LEN = 80
|
||||
|
||||
|
||||
def detect_invented_text(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
min_length: int = _INVENTED_TEXT_MIN_LENGTH,
|
||||
) -> list[str]:
|
||||
"""Detect HTML text fragments that are not anchored in the source MDX.
|
||||
|
||||
Phase Z port of the H3 hallucination guard (Phase Q reference:
|
||||
``src/content_verifier.py:276-315``). Pipeline (order matters):
|
||||
drop short fragments (< ``min_length``), drop structural label
|
||||
exceptions in ``_INVENTED_TEXT_ALLOWED_LABELS``, drop CSS/numeric
|
||||
noise matching ``_INVENTED_TEXT_CSS_NUMBER_PATTERN``, then per
|
||||
surviving fragment compute keyword ratio (via u4 ``extract_keywords``
|
||||
on the normalized fragment, checked against the normalized MDX). A
|
||||
fragment is flagged when ``kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD``;
|
||||
flagged values are truncated to ``_INVENTED_TEXT_TRUNCATE_LEN`` chars
|
||||
before being returned. Empty keyword sets short-circuit as
|
||||
non-falsifiable (matches Phase Q parity). Deterministic, pure.
|
||||
"""
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
norm_mdx = normalize_for_comparison(original_mdx)
|
||||
invented: list[str] = []
|
||||
for text in html_texts:
|
||||
text = text.strip()
|
||||
if len(text) < min_length:
|
||||
continue
|
||||
if text in _INVENTED_TEXT_ALLOWED_LABELS:
|
||||
continue
|
||||
if _INVENTED_TEXT_CSS_NUMBER_PATTERN.match(text):
|
||||
continue
|
||||
norm_text = normalize_for_comparison(text)
|
||||
keywords = extract_keywords(norm_text)
|
||||
if not keywords:
|
||||
continue
|
||||
kw_found = sum(1 for kw in keywords if kw in norm_mdx)
|
||||
kw_ratio = kw_found / len(keywords)
|
||||
if kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD:
|
||||
invented.append(text[:_INVENTED_TEXT_TRUNCATE_LEN])
|
||||
return invented
|
||||
@@ -1,11 +1,23 @@
|
||||
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
|
||||
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1280">
|
||||
<title>{{ slide_title }}</title>
|
||||
{% if embedded_mode == "auto" %}
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('embedded') === '1' || window.self !== window.top) {
|
||||
document.documentElement.classList.add('embedded');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<style>
|
||||
/* ── existing tokens (inlined) ── */
|
||||
{{ token_css | safe }}
|
||||
@@ -20,6 +32,19 @@
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ── IMP-14 A-4: embedded mode reset (iframe consumer) ──
|
||||
standalone-only body centering/min-height/padding undone so the .slide
|
||||
(1280×720) sits at origin without vertical shift or clipping. */
|
||||
html.embedded body {
|
||||
background: transparent;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html.embedded .slide {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── 16:9 슬라이드 (single, 1280×720) ── */
|
||||
.slide {
|
||||
width: 1280px; height: 720px;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""P4 (2026-05-19) — audit-only mode verification.
|
||||
|
||||
Covers:
|
||||
- _is_audit_issue: title pattern detection (positive + negative)
|
||||
- _audit_mode: title-based + CLI override (AUDIT_ONLY_OVERRIDE)
|
||||
- _check_audit_only_violations: forbidden prefix detection via mocked git status
|
||||
- AUDIT_ONLY_NOTE injection into context pack (via build_context_pack contract)
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_audit_mode.py
|
||||
"""
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import orchestrator # noqa: E402
|
||||
from orchestrator import ( # noqa: E402
|
||||
_is_audit_issue,
|
||||
_audit_mode,
|
||||
_check_audit_only_violations,
|
||||
_check_audit_commit_scope,
|
||||
_ensure_audit_baseline,
|
||||
_load_audit_baseline,
|
||||
_audit_baseline_path,
|
||||
AUDIT_ONLY_FORBIDDEN_PREFIXES,
|
||||
AUDIT_ONLY_NOTE,
|
||||
AUDIT_ALLOWED_COMMIT_GLOBS,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _is_audit_issue — title detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsAuditIssue:
|
||||
def test_integration_audit_bracket(self):
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-01] cumulative review") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-02] something") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT] no number") is True
|
||||
|
||||
def test_audit_only_bracket(self):
|
||||
assert _is_audit_issue("[AUDIT-ONLY] doc consistency check") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_audit_issue("[integration-audit-03] foo") is True
|
||||
assert _is_audit_issue("[Audit-Only] bar") is True
|
||||
|
||||
def test_plain_integration_audit_phrase(self):
|
||||
assert _is_audit_issue("Quarterly integration audit for closed issues") is True
|
||||
assert _is_audit_issue("Integration Audit Q2") is True
|
||||
|
||||
def test_execution_issue_not_audit(self):
|
||||
"""execution sub-issue 가 audit 로 잘못 감지되면 안 됨."""
|
||||
assert _is_audit_issue("[IMP-15 실행-1] image_aspect_mismatch") is False
|
||||
assert _is_audit_issue("[IMP-15 exec-2] table overflow") is False
|
||||
|
||||
def test_unrelated_issues(self):
|
||||
assert _is_audit_issue("IMP-19 I4 zone 비중 분배") is False
|
||||
assert _is_audit_issue("Fix overflow bug") is False
|
||||
assert _is_audit_issue("docs(IMP-06): Stage 4 fix") is False
|
||||
|
||||
def test_empty_or_none(self):
|
||||
assert _is_audit_issue("") is False
|
||||
assert _is_audit_issue(None) is False
|
||||
|
||||
def test_audit_word_in_random_position_no_match(self):
|
||||
"""'audit' 가 단독으로 나오는 건 안 잡아야 함 — 'integration audit' 만."""
|
||||
assert _is_audit_issue("audit some code") is False
|
||||
assert _is_audit_issue("security audit") is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _audit_mode — combination with CLI override
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditMode:
|
||||
def setup_method(self):
|
||||
# 각 테스트 전에 override 리셋.
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def teardown_method(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def test_title_based_only(self):
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01] foo") is True
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
|
||||
def test_cli_override_forces_audit(self):
|
||||
"""title 에 marker 없어도 CLI flag 가 audit mode 강제."""
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = True
|
||||
assert _audit_mode("IMP-19 zone") is True
|
||||
assert _audit_mode("any title") is True
|
||||
assert _audit_mode("") is True
|
||||
|
||||
def test_override_off_falls_back_to_title(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01]") is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _check_audit_only_violations — git status parsing
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, stdout, returncode=0):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
class TestCheckAuditOnlyViolations:
|
||||
"""subprocess.run 을 monkeypatch 해서 다양한 git status 출력 시나리오 검증."""
|
||||
|
||||
def test_clean_tree(self, monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
return _FakeCompleted(stdout="")
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_only_allowed_changes(self, monkeypatch):
|
||||
"""docs/architecture 변경만 있으면 violation 0."""
|
||||
stdout = (
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"?? docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
" M docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_src_change_detected(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/phase_z2_pipeline.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_templates_change_detected(self, monkeypatch):
|
||||
stdout = " M templates/phase_z2/families/something.html\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["templates/phase_z2/families/something.html"]
|
||||
|
||||
def test_tests_change_detected(self, monkeypatch):
|
||||
stdout = " M tests/phase_z2/test_overflow.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["tests/phase_z2/test_overflow.py"]
|
||||
|
||||
def test_multiple_violations(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/a.py\n"
|
||||
"?? src/b.py\n"
|
||||
" M templates/c.html\n"
|
||||
" M tests/d.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n" # allowed
|
||||
" M data/runs/run123.json\n" # allowed (not in forbidden)
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert set(v) == {"src/a.py", "src/b.py", "templates/c.html", "tests/d.py"}
|
||||
|
||||
def test_renamed_file_destination_checked(self, monkeypatch):
|
||||
"""rename 의 경우 destination 만 검사."""
|
||||
stdout = "R docs/old.md -> src/new.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/new.py"]
|
||||
|
||||
def test_windows_backslash_path(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 로 정규화돼서 매치."""
|
||||
stdout = " M src\\phase_z2_pipeline.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_quoted_path_with_spaces(self, monkeypatch):
|
||||
"""공백/특수문자 포함 path 는 quoted — quote strip 후 검사."""
|
||||
stdout = ' M "src/some file.py"\n'
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/some file.py"]
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git 자체 실패 → 가드 false positive 안 만들고 빈 list 반환."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_subprocess_exception_fails_open(self, monkeypatch):
|
||||
"""subprocess.run 자체가 raise 해도 가드 false positive X."""
|
||||
def boom(*a, **kw): raise RuntimeError("git missing")
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# AUDIT_ONLY_NOTE constants — sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: baseline-aware violations
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBaselineAwareViolations:
|
||||
def test_baseline_subtraction_removes_preexisting(self, monkeypatch):
|
||||
"""pre-existing forbidden path 는 baseline 에 있으면 violation 에서 제외."""
|
||||
stdout = (
|
||||
" M src/already_dirty.py\n" # baseline 안에 있음 — 제외돼야 함
|
||||
" M src/new_violation.py\n" # baseline 밖 — violation
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/already_dirty.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == ["src/new_violation.py"]
|
||||
|
||||
def test_baseline_none_keeps_all(self, monkeypatch):
|
||||
"""baseline=None 이면 기존 동작 — 모든 forbidden 잡음."""
|
||||
stdout = " M src/a.py\n M src/b.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=None)
|
||||
assert set(v) == {"src/a.py", "src/b.py"}
|
||||
|
||||
def test_baseline_empty_set_keeps_all(self, monkeypatch):
|
||||
"""baseline=set() 이면 모두 새 violation 으로 잡음."""
|
||||
stdout = " M src/a.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=set())
|
||||
assert v == ["src/a.py"]
|
||||
|
||||
def test_baseline_filters_all_violations(self, monkeypatch):
|
||||
"""모든 violation 이 baseline 에 있으면 빈 list 반환 — clean 으로 판정."""
|
||||
stdout = " M src/a.py\n M templates/b.html\n M tests/c.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/a.py", "templates/b.html", "tests/c.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
def test_baseline_path_normalized_match(self, monkeypatch):
|
||||
"""baseline 의 path 는 forward-slash 정규화 형태. Windows backslash 도 매치."""
|
||||
stdout = " M src\\windows_path.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/windows_path.py"} # baseline 도 forward-slash 형태로 저장
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _ensure_audit_baseline / _load_audit_baseline
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditBaselinePersist:
|
||||
def test_save_and_load_roundtrip(self, monkeypatch, tmp_path):
|
||||
"""baseline 저장 → 로드 → 동일 path set 반환."""
|
||||
# Redirect ORCH_DIR to tmp_path for isolation.
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Mock git status output.
|
||||
stdout = " M src/a.py\n?? src/b.py\n M docs/c.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
assert loaded == {"src/a.py", "src/b.py", "docs/c.md"}
|
||||
|
||||
def test_ensure_does_not_overwrite_existing(self, monkeypatch, tmp_path):
|
||||
"""이미 baseline 파일 있으면 덮어쓰지 않음 — resumed run 의 가드 일관성."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# First save with one set.
|
||||
stdout1 = " M src/original.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout1))
|
||||
_ensure_audit_baseline(999)
|
||||
# Second call with DIFFERENT git status — should NOT overwrite.
|
||||
stdout2 = " M src/different.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout2))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
# Original baseline preserved.
|
||||
assert loaded == {"src/original.py"}
|
||||
|
||||
def test_load_missing_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
assert _load_audit_baseline(8888) == set()
|
||||
|
||||
def test_load_corrupt_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Manually write corrupt JSON.
|
||||
p = tmp_path / "audit_baseline_7777.json"
|
||||
p.write_text("not valid json {{{", encoding="utf-8")
|
||||
assert _load_audit_baseline(7777) == set()
|
||||
|
||||
def test_load_non_list_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
"""baseline 파일이 list 가 아닌 다른 JSON (예: dict) 이면 empty set."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
p = tmp_path / "audit_baseline_6666.json"
|
||||
p.write_text('{"unexpected": "shape"}', encoding="utf-8")
|
||||
assert _load_audit_baseline(6666) == set()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _check_audit_commit_scope — Stage 5 guard
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitScope:
|
||||
def test_clean_commit_audit_report_only(self, monkeypatch):
|
||||
"""audit report 파일만 commit 되면 통과."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_backlog_update_allowed(self, monkeypatch):
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_src_file_in_commit_detected(self, monkeypatch):
|
||||
"""audit commit 에 src/ 파일이 끼면 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"src/phase_z2_pipeline.py\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_unrelated_doc_detected(self, monkeypatch):
|
||||
"""docs/ 라도 audit 관련 아닌 doc 은 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/some_other_doc.md\n" # 다른 doc
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md\n" # audit 와 무관한 doc
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert set(v) == {"docs/some_other_doc.md",
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md"}
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git show 자체 실패 → 빈 list (가드가 false positive 만들지 않음)."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_windows_backslash_normalized(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 정규화 후 glob 매치."""
|
||||
stdout = "docs\\architecture\\INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_empty_commit_passes(self, monkeypatch):
|
||||
"""commit 에 파일 변경 없음 (보통 안 일어나지만) — 위반 없음."""
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=""))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: allowed-glob shape sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitAllowedGlobs:
|
||||
def test_globs_have_audit_marker(self):
|
||||
"""모든 allowed glob 에 INTEGRATION-AUDIT 또는 BACKLOG 마커 존재."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert ("INTEGRATION-AUDIT" in g) or ("BACKLOG" in g)
|
||||
|
||||
def test_globs_under_docs_architecture(self):
|
||||
"""모든 allowed path 가 docs/architecture/ 산하 — src/ 등 우발적 허용 차단."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert g.startswith("docs/architecture/"), f"glob escapes docs/architecture/: {g}"
|
||||
|
||||
|
||||
class TestAuditOnlyConstants:
|
||||
def test_note_mentions_forbidden_prefixes(self):
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p in AUDIT_ONLY_NOTE, f"AUDIT_ONLY_NOTE missing prefix mention: {p}"
|
||||
|
||||
def test_note_mentions_allowed_paths(self):
|
||||
assert "INTEGRATION-AUDIT-*.md" in AUDIT_ONLY_NOTE
|
||||
assert "PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md" in AUDIT_ONLY_NOTE
|
||||
|
||||
def test_note_states_no_code_edit(self):
|
||||
# "report" 또는 "NOT code" 표현 명시 확인 (LLM 가독성 가드).
|
||||
lower = AUDIT_ONLY_NOTE.lower()
|
||||
assert "audit report" in lower or "report writing" in lower
|
||||
assert "not code" in lower or "no production" in lower
|
||||
|
||||
def test_forbidden_prefixes_no_trailing_slash_issues(self):
|
||||
"""블랙리스트는 startswith 매치 — 'src' (slash 없음) 면 'srcfoo.py' 도 매칭돼서 false positive.
|
||||
모든 prefix 가 '/' 로 끝나야 함."""
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p.endswith("/"), f"prefix '{p}' must end with '/' to avoid false matches"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""IMP-17 u1 (2026-05-19) — comment anchor for src/phase_z2_pipeline.py route hint table.
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
PIPELINE = ROOT / "src" / "phase_z2_pipeline.py"
|
||||
|
||||
|
||||
def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_564_references_imp17_not_imp31():
|
||||
line = _lines()[563] # 1-indexed line 564
|
||||
assert "restructure" in line, f"line 564 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 564 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 564 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_565_still_references_imp29():
|
||||
line = _lines()[564] # 1-indexed line 565
|
||||
assert "reject" in line, f"line 565 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 565 must still reference IMP-29 frontend override: {line!r}"
|
||||
@@ -0,0 +1,247 @@
|
||||
"""P0-3 (2026-05-18) — orchestrator self-test minimum set.
|
||||
|
||||
Covers detect_agent (the bug that caused #45 infinite loop), parse_consensus,
|
||||
parse_remaining_units, IMPLEMENTATION_UNITS parsing, dual-write normalize.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add design_agent root to sys.path so we can import orchestrator.py
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
detect_agent,
|
||||
parse_consensus,
|
||||
parse_remaining_units,
|
||||
_is_execution_issue,
|
||||
)
|
||||
import re
|
||||
|
||||
|
||||
class TestExecutionIssueDetection:
|
||||
"""P1-4 — execution sub-issue title detection."""
|
||||
|
||||
def test_execution_korean_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 실행-1] image_aspect_mismatch") is True
|
||||
assert _is_execution_issue("[IMP-15 실행-2] table overflow") is True
|
||||
assert _is_execution_issue("[IMP-15 실행 3] something") is True
|
||||
|
||||
def test_execution_english_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 exec-1] image") is True
|
||||
assert _is_execution_issue("[IMP-15 EXEC 2] table") is True
|
||||
|
||||
def test_non_execution_title(self):
|
||||
assert _is_execution_issue("IMP-15 Step 14 visual_check 보강") is False
|
||||
assert _is_execution_issue("IMP-09 B-4 다른 layout zone-geometry") is False
|
||||
|
||||
def test_empty_title(self):
|
||||
assert _is_execution_issue("") is False
|
||||
assert _is_execution_issue(None) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# detect_agent — the bug that caused #45 infinite loop
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectAgent:
|
||||
def test_claude_header(self):
|
||||
assert detect_agent("[Claude #1] Stage 1 ...") == "claude"
|
||||
|
||||
def test_codex_header(self):
|
||||
assert detect_agent("[Codex #1] Stage 1 review") == "codex"
|
||||
|
||||
def test_codex_body_with_claude_citation(self):
|
||||
"""The exact bug from #45 — Codex body contains [Claude #N] citation in
|
||||
EVIDENCE section. Old detect_agent returned 'claude' (wrong)."""
|
||||
body = """[Codex #2] Stage 2 Round #1 simulation-plan verification
|
||||
|
||||
Verdict: NO.
|
||||
|
||||
=== EVIDENCE ===
|
||||
- Read current-stage Gitea comment `[Claude #2] Stage 2 Round #1 - Plan` only
|
||||
"""
|
||||
assert detect_agent(body) == "codex", \
|
||||
"Codex body containing [Claude #N] citation must still detect as codex"
|
||||
|
||||
def test_claude_body_with_codex_citation(self):
|
||||
body = """[Claude #3] Stage 2 Round #2 - Plan
|
||||
|
||||
Addressing [Codex #2] findings ...
|
||||
"""
|
||||
assert detect_agent(body) == "claude"
|
||||
|
||||
def test_empty_body(self):
|
||||
assert detect_agent("") is None
|
||||
assert detect_agent(None) is None
|
||||
assert detect_agent(" \n ") is None
|
||||
|
||||
def test_no_agent_header(self):
|
||||
assert detect_agent("This is some random text without any agent marker") is None
|
||||
|
||||
def test_leading_whitespace_before_header(self):
|
||||
body = " \n[Codex #1] header after whitespace"
|
||||
assert detect_agent(body) == "codex"
|
||||
|
||||
def test_header_must_be_at_start(self):
|
||||
"""Body that doesn't start with [Agent header should return None."""
|
||||
body = "Some intro text.\n[Codex #1] header on second line"
|
||||
# P0-1 fix: only first non-empty line is checked.
|
||||
# First line = "Some intro text." → no match → None
|
||||
assert detect_agent(body) is None
|
||||
|
||||
def test_header_with_hash_immediately(self):
|
||||
"""[Codex#1] (no space) should still match per regex \\[Codex[\\s#]."""
|
||||
assert detect_agent("[Codex#1] hello") == "codex"
|
||||
assert detect_agent("[Claude#5] hi") == "claude"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_consensus — YES/NO + rewind_target
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseConsensus:
|
||||
def test_yes_only(self):
|
||||
body = "Some text.\nFINAL_CONSENSUS: YES"
|
||||
assert parse_consensus(body) == ("YES", None)
|
||||
|
||||
def test_no_with_rewind_target(self):
|
||||
body = "Some text.\nrewind_target: stage_2_plan\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "stage_2_plan")
|
||||
|
||||
def test_no_with_continue_same(self):
|
||||
body = "blah\nrewind_target: continue_same\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "continue_same")
|
||||
|
||||
def test_no_target_only_in_last_10_lines(self):
|
||||
"""parse_consensus only scans last 10 lines."""
|
||||
body = "rewind_target: stage_1_review\n" + "\n".join(["filler"] * 20) + "\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # too far from end to be picked up
|
||||
|
||||
def test_no_consensus_marker(self):
|
||||
assert parse_consensus("just text, no marker") == (None, None)
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_consensus("") == (None, None)
|
||||
assert parse_consensus(None) == (None, None)
|
||||
|
||||
def test_unknown_rewind_target_ignored(self):
|
||||
body = "rewind_target: bogus_target\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # bogus is not in REWIND_TARGET_TO_SID
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_remaining_units — Stage 3 continue_same progress detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseRemainingUnits:
|
||||
def test_bracketed_list(self):
|
||||
body = "Remaining units: [u2, u3, u4]"
|
||||
assert parse_remaining_units(body) == {"u2", "u3", "u4"}
|
||||
|
||||
def test_comma_list_no_brackets(self):
|
||||
body = "Remaining units: u5, u6, u7"
|
||||
assert parse_remaining_units(body) == {"u5", "u6", "u7"}
|
||||
|
||||
def test_none_explicit(self):
|
||||
assert parse_remaining_units("Remaining units: none") == set()
|
||||
assert parse_remaining_units("Remaining units: []") == set()
|
||||
assert parse_remaining_units("Remaining units: (none)") == set()
|
||||
assert parse_remaining_units("Remaining units: -") == set()
|
||||
|
||||
def test_line_not_present(self):
|
||||
assert parse_remaining_units("no remaining units mentioned here") is None
|
||||
|
||||
def test_case_insensitive(self):
|
||||
body = "REMAINING UNITS: [U1, U2]"
|
||||
assert parse_remaining_units(body) == {"u1", "u2"}
|
||||
|
||||
def test_only_u_prefixed_digits(self):
|
||||
"""Sentence noise ignored — only u\\d+ pattern matched."""
|
||||
body = "Remaining units: I still need to do u3 and u7 work"
|
||||
assert parse_remaining_units(body) == {"u3", "u7"}
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_remaining_units("") is None
|
||||
assert parse_remaining_units(None) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# IMPLEMENTATION_UNITS block parsing (used in Stage 2 YES guard)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestImplementationUnitsBlock:
|
||||
"""Reproduces the parser in run_stage Stage 2 YES guard (line ~810)."""
|
||||
|
||||
def _parse(self, body):
|
||||
iu_block_pat = re.compile(
|
||||
r"===\s*IMPLEMENTATION_UNITS\s*===\s*\n(.*?)(?=\n===\s|\Z)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
iu_unit_pat = re.compile(r"^\s*-\s*id:\s*u\d+", re.IGNORECASE | re.MULTILINE)
|
||||
m = iu_block_pat.search(body or "")
|
||||
return bool(m and iu_unit_pat.search(m.group(1)))
|
||||
|
||||
def test_valid_block(self):
|
||||
body = """text
|
||||
|
||||
=== IMPLEMENTATION_UNITS ===
|
||||
- id: u1
|
||||
summary: ...
|
||||
- id: u2
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is True
|
||||
|
||||
def test_empty_block(self):
|
||||
body = "=== IMPLEMENTATION_UNITS ===\n(no entries)\n"
|
||||
assert self._parse(body) is False # header but no - id: uN entry
|
||||
|
||||
def test_block_missing(self):
|
||||
body = "just text, no implementation_units"
|
||||
assert self._parse(body) is False
|
||||
|
||||
def test_block_with_only_non_u_entries(self):
|
||||
body = """=== IMPLEMENTATION_UNITS ===
|
||||
- id: alpha
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is False # 'alpha' is not 'u\\d+'
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Direct integration check — the #45 bug case
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegressionForIssue45Bug:
|
||||
"""Verify the exact body shape that caused #45 infinite loop is now handled."""
|
||||
|
||||
def test_codex_no_with_claude_citation_full_flow(self):
|
||||
body = """[Codex #3] Stage 2 Round #2 simulation-plan verification for issue #45
|
||||
|
||||
Verdict: NO. The plan covers main axes but violates two Stage 2 requirements.
|
||||
|
||||
Findings:
|
||||
- Unit u1 declares tests: [] in === IMPLEMENTATION_UNITS ===
|
||||
- xfail-strict mechanism unclear
|
||||
|
||||
=== EVIDENCE ===
|
||||
Commands run:
|
||||
- git rev-parse HEAD
|
||||
- Read current-stage Gitea comment `[Claude #3] Stage 2 Round #2 - Plan`
|
||||
|
||||
rewind_target: stage_2_plan
|
||||
FINAL_CONSENSUS: NO
|
||||
"""
|
||||
# P0-1 fix: detect_agent reads only first line → "[Codex #3]" → codex
|
||||
assert detect_agent(body) == "codex", "P0-1 regression test"
|
||||
# parse_consensus: NO + rewind_target stage_2_plan
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target == "stage_2_plan"
|
||||
@@ -0,0 +1,310 @@
|
||||
"""P3-5 (2026-05-18) — subprocess cleanup hardening verification.
|
||||
|
||||
Covers:
|
||||
C1: 정상 종료 → tree 잔류 0
|
||||
C2: timeout → TimeoutExpired raise + 자손 0
|
||||
C3: grandchild spawn 후 parent timeout → grandchild 정리
|
||||
C4: 외부 (orchestrator 가 spawn 안한) 프로세스 보호
|
||||
C5: _kill_process_tree(self.pid) 호출해도 orchestrator 자살 안 함
|
||||
C6 (CORE): parent 정상 종료 후 grandchild orphan 정리 — PID 2780 regression
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_subprocess_cleanup.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
_kill_process_tree,
|
||||
_kill_tracked,
|
||||
_run_with_tree_kill,
|
||||
_proc_signature,
|
||||
_is_same_process,
|
||||
_SPAWNED,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _py():
|
||||
"""Path to current Python interpreter — used to spawn dummy subprocesses."""
|
||||
return sys.executable
|
||||
|
||||
def _alive(pid):
|
||||
try:
|
||||
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.NoSuchProcess:
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Signature helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSignatureHelpers:
|
||||
def test_proc_signature_alive(self):
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
assert sig is not None
|
||||
assert sig[0] == os.getpid()
|
||||
assert isinstance(sig[1], float)
|
||||
|
||||
def test_is_same_process_orch_self_blocked(self):
|
||||
"""C5 prep — orchestrator 자기 자신은 절대 same-process true 안 됨."""
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
# _is_same_process 가 _ORCH_PID 체크로 False 반환해야 함.
|
||||
assert _is_same_process(sig[0], sig[1]) is False
|
||||
|
||||
def test_is_same_process_dead_pid(self):
|
||||
# 사용 가능성 낮은 PID 999999 — 거의 확실히 죽음.
|
||||
assert _is_same_process(999999, time.time()) is False
|
||||
|
||||
def test_is_same_process_wrong_create_time(self):
|
||||
"""PID 재사용 회피 검증 — 같은 PID 라도 create_time 안 맞으면 False."""
|
||||
# 살아있는 외부 프로세스 빌려서 일부러 어긋난 create_time 으로 호출.
|
||||
# System Idle 같은 특수 프로세스 (create_time=0) 회피 — 우리가 띄운 dummy 사용.
|
||||
dummy = subprocess.Popen(
|
||||
[_py(), "-c", "import time; time.sleep(5)"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
# 실제 create_time 보다 1 년 전 시각 → 명백한 mismatch.
|
||||
far_past = time.time() - 365 * 24 * 3600
|
||||
assert _is_same_process(dummy.pid, far_past) is False
|
||||
# 맞는 create_time 으로는 True 여야 함 (sanity).
|
||||
real_ct = psutil.Process(dummy.pid).create_time()
|
||||
assert _is_same_process(dummy.pid, real_ct) is True
|
||||
finally:
|
||||
dummy.kill()
|
||||
dummy.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C1: 정상 종료 — tree 잔류 0
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1_NormalExit:
|
||||
def test_dummy_short_run_no_residue(self):
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(0.3)"],
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# 호출 후 _SPAWNED 에 우리 호출 잔재가 남으면 안 됨 (wrapper 가 discard).
|
||||
# 다른 테스트 영향 가능성 있어서 set 전체가 0 이 아니어도 됨, 단 우리 잔재 없으면 OK.
|
||||
# 보수적으로 — 우리 호출 직전에 _SPAWNED 가 비어있었으면 직후에도 비어있어야 함.
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C2: Timeout — TimeoutExpired raise + 자손 정리
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2_Timeout:
|
||||
def test_dummy_long_sleep_times_out(self):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(60)"],
|
||||
timeout=1.5,
|
||||
)
|
||||
# raise 후에도 _SPAWNED 우리 잔재 없어야 함 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C3: grandchild orphan 정리 — parent timeout path
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC3_GrandchildTimeoutPath:
|
||||
def test_grandchild_killed_on_parent_timeout(self):
|
||||
# parent 가 grandchild 띄우고 자기는 sleep — timeout 으로 강제 종료.
|
||||
# grandchild 도 정리돼야 함.
|
||||
# PID 캡처를 위해 grandchild 가 자기 PID 를 파일에 기록.
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c3_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# grandchild 의 stdin/stdout/stderr 를 DEVNULL 로 분리 — production 의 claude.exe→python.exe -
|
||||
# 케이스와 동일 (grandchild 가 wrapper 의 pipe 핸들 안 상속). 안 그러면 pipe inheritance 로
|
||||
# communicate() 가 hang.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(60)"
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# marker 파일에서 grandchild PID 읽기.
|
||||
assert marker.exists(), "grandchild marker not written — parent died too early"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 잠시 대기 (cleanup 비동기 가능성) 후 grandchild 죽었는지 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), f"grandchild PID {gc_pid} still alive after parent timeout"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C4: 외부 프로세스 보호
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC4_ExternalProcessProtection:
|
||||
def test_outsider_not_killed(self):
|
||||
# 사용자가 직접 띄운 척하는 외부 프로세스 (orchestrator 가 spawn 안 함).
|
||||
outsider = subprocess.Popen([_py(), "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
# _kill_tracked 에 외부 PID 의 (잘못된) signature 넘기면 무시돼야 함.
|
||||
# signature 일치 안 하면 _is_same_process False → kill 안 됨.
|
||||
wrong_sig = [(outsider.pid, 0.0)] # create_time 안 맞음
|
||||
cleaned = _kill_tracked(wrong_sig)
|
||||
assert cleaned == 0
|
||||
assert _alive(outsider.pid), "outsider killed despite wrong create_time"
|
||||
finally:
|
||||
outsider.kill()
|
||||
outsider.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C5: orchestrator 자살 방지
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC5_SelfKillProtection:
|
||||
def test_kill_process_tree_self_pid_noop(self):
|
||||
"""orchestrator(=pytest) PID 로 _kill_process_tree 호출해도 죽으면 안 됨."""
|
||||
result = _kill_process_tree(os.getpid())
|
||||
assert result == 0 # ORCH_PID 검사로 즉시 0 반환
|
||||
|
||||
def test_kill_tracked_with_orch_pid_noop(self):
|
||||
# 일부러 self signature 를 tracked 에 넣어도 _is_same_process False → skip.
|
||||
self_p = psutil.Process(os.getpid())
|
||||
self_sig = _proc_signature(self_p)
|
||||
cleaned = _kill_tracked([self_sig])
|
||||
assert cleaned == 0 # 자기 자신 보호
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C6 (CORE): parent 정상 종료 후 grandchild orphan 정리
|
||||
# — PID 2780 regression test
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC6_OrphanGrandchildAfterNormalExit:
|
||||
"""PID 2780 path: parent 가 정상 exit 했는데 grandchild 만 살아남는 케이스.
|
||||
monitor thread 가 parent 살아있을 때 grandchild 를 미리 추적해서 finally 에서 정리해야 함."""
|
||||
|
||||
def test_grandchild_killed_after_parent_normal_exit(self):
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c6_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# parent 가:
|
||||
# 1. grandchild 띄움 (DEVNULL 격리 — production claude.exe→python.exe - 과 동등).
|
||||
# 2. PID 마커에 기록.
|
||||
# 3. monitor 가 1초 polling 으로 catch 할 시간 확보 (2.5초 sleep).
|
||||
# 4. 정상 종료.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(2.5); "
|
||||
f"sys.exit(0)"
|
||||
)
|
||||
|
||||
# 정상 종료 (timeout 안 걸림) — wrapper 의 finally cleanup 만으로 grandchild 잡혀야 함.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=15,
|
||||
)
|
||||
assert r.returncode == 0, "parent did not exit normally"
|
||||
|
||||
# marker 에서 grandchild PID.
|
||||
assert marker.exists(), "grandchild marker missing"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 정리 비동기 가능성 → 짧게 대기 후 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), (
|
||||
f"REGRESSION: grandchild PID {gc_pid} survived parent normal exit "
|
||||
f"(PID 2780 path not fixed)"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C7: input + encoding path — run_claude 가 실제 사용하는 호출 모드.
|
||||
# 2026-05-18 production bug: str input + encoding="utf-8" 일 때
|
||||
# wrapper 가 input 을 강제로 bytes 인코딩 → Popen text mode pipe 에
|
||||
# bytes 쓰려다 TypeError: write() argument must be str, not bytes.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7_InputEncodingPath:
|
||||
def test_str_input_with_encoding_utf8(self):
|
||||
"""run_claude 와 동일한 호출 모드 — input=str + encoding='utf-8'."""
|
||||
# stdin 에서 읽은 그대로 stdout 으로 echo. 한글 포함해서 encoding 검증.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.write(sys.stdin.read())"],
|
||||
input="hello 안녕\n",
|
||||
encoding="utf-8",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# encoding= 모드면 stdout 는 str 이어야 함.
|
||||
assert isinstance(r.stdout, str)
|
||||
assert "hello" in r.stdout
|
||||
assert "안녕" in r.stdout
|
||||
|
||||
def test_bytes_input_without_encoding(self):
|
||||
"""encoding 없으면 binary mode — input=bytes 그대로 통과."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input=b"raw bytes",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout == b"raw bytes"
|
||||
|
||||
def test_str_input_without_encoding_auto_encoded(self):
|
||||
"""input=str 인데 encoding 없으면 wrapper 가 자동 utf-8 인코딩."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input="auto encode 한글",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout.decode("utf-8") == "auto encode 한글"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Bonus: _SPAWNED discipline — 다중 호출 후 누적 안 됨
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSpawnedDiscipline:
|
||||
def test_spawned_drained_between_calls(self):
|
||||
for _ in range(3):
|
||||
_run_with_tree_kill([_py(), "-c", "pass"], timeout=10)
|
||||
# 3 회 호출 후에도 우리 잔재 없음 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
@@ -0,0 +1,43 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,63 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.55
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.55
|
||||
y: 0
|
||||
w: 0.45
|
||||
h: 0.4
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 0.55
|
||||
h: 0.6
|
||||
bottom-right:
|
||||
x: 0.55
|
||||
y: 0.4
|
||||
w: 0.45
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 641px 525px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 641
|
||||
- 525
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.55
|
||||
- 0.45
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 777
|
||||
- 389
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.658
|
||||
- 0.33
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right-top:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
right-bottom:
|
||||
x: 0.4
|
||||
y: 0.5
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 466px 700px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 389
|
||||
- 777
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.33
|
||||
- 0.658
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left-top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.35
|
||||
h: 0.6
|
||||
left-bottom:
|
||||
x: 0
|
||||
y: 0.6
|
||||
w: 0.35
|
||||
h: 0.4
|
||||
right:
|
||||
x: 0.35
|
||||
y: 0
|
||||
w: 0.65
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 408px 758px
|
||||
rows: 343px 228px
|
||||
heights_px:
|
||||
- 343
|
||||
- 228
|
||||
widths_px:
|
||||
- 408
|
||||
- 758
|
||||
ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
width_ratios:
|
||||
- 0.35
|
||||
- 0.65
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px:
|
||||
- 314
|
||||
- 257
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.537
|
||||
- 0.439
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
bottom-right:
|
||||
x: 0.5
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 171px 400px
|
||||
heights_px:
|
||||
- 171
|
||||
- 400
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px:
|
||||
- 257
|
||||
- 314
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.439
|
||||
- 0.537
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.6
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 0.4
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 1.0
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 700px 466px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 700
|
||||
- 466
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: grid-2x2_dynamic_2d
|
||||
description: |
|
||||
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
|
||||
Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-1-right-2_dynamic_2d
|
||||
description: |
|
||||
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [777, 389]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.658, 0.33]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-2-right-1_dynamic_2d
|
||||
description: |
|
||||
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [389, 777]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.33, 0.658]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,26 @@
|
||||
case_id: top-1-bottom-2_dynamic_2d
|
||||
description: |
|
||||
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
|
||||
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
|
||||
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
|
||||
skip reason, because row-only redistribution cannot reconcile both
|
||||
axes simultaneously.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px: [314, 257]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.537, 0.439]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: top-2-bottom-1_dynamic_2d
|
||||
description: |
|
||||
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px: [257, 314]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.439, 0.537]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -138,21 +138,21 @@ def test_vertical_2_override_keeps_fr_cols_legacy():
|
||||
assert result["width_ratios"] == [0.4, 0.6]
|
||||
|
||||
|
||||
# ────────────────────── fr_default sink (PR 1) ──────────────────────
|
||||
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
|
||||
|
||||
|
||||
def test_top_1_bottom_2_fr_default_populates_geometry():
|
||||
"""T-shape (top-1-bottom-2) falls through to fr_default in PR 1
|
||||
but heights_px / widths_px must be populated (length-locked to
|
||||
grid R=2, C=2)."""
|
||||
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
|
||||
builder in PR 2: heights_px / widths_px length-locked to grid
|
||||
R=2, C=2 with both dynamic flags True."""
|
||||
zones = [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]
|
||||
result = build_layout_css("top-1-bottom-2", zones)
|
||||
assert result["computation"] == "fr_default_from_preset"
|
||||
assert result["dynamic_rows"] is False
|
||||
assert result["dynamic_cols"] is False
|
||||
assert result["computation"] == "2d_dynamic_aggregated"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is True
|
||||
assert len(result["heights_px"]) == 2 # R rows
|
||||
assert len(result["widths_px"]) == 2 # C cols
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing.
|
||||
|
||||
Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of
|
||||
``visual_runtime_check`` and exposes them as top-level keys, mirroring the
|
||||
existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739).
|
||||
|
||||
Two scenarios:
|
||||
|
||||
* Populated — ``visual_runtime_check`` carries non-empty event lists; the
|
||||
written debug dict surfaces both at the top level with identical payloads.
|
||||
* None — ``visual_runtime_check is None``; both top-level keys default to ``[]``
|
||||
(no KeyError, no propagated None).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_pipeline import write_debug_json
|
||||
|
||||
|
||||
def _read_debug(run_dir: Path) -> dict:
|
||||
return json.loads((run_dir / "debug.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None:
|
||||
image_events = [
|
||||
{
|
||||
"src": "img/a.png",
|
||||
"zone_position": "primary",
|
||||
"zone_template_id": "tid-1",
|
||||
"natural_w": 200,
|
||||
"natural_h": 100,
|
||||
"rendered_w": 200,
|
||||
"rendered_h": 200,
|
||||
"delta": 1.0,
|
||||
}
|
||||
]
|
||||
table_events = [
|
||||
{
|
||||
"zone_position": "secondary",
|
||||
"zone_template_id": "tid-2",
|
||||
"clientWidth": 300,
|
||||
"scrollWidth": 360,
|
||||
"excess_x": 60,
|
||||
"wrapper_clipped_index": 0,
|
||||
}
|
||||
]
|
||||
visual_runtime_check = {
|
||||
"image_events": image_events,
|
||||
"table_events": table_events,
|
||||
"zone_geometries_px": [],
|
||||
}
|
||||
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=visual_runtime_check,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert "image_events" in debug, "image_events must be a top-level key"
|
||||
assert "table_events" in debug, "table_events must be a top-level key"
|
||||
assert debug["image_events"] == image_events
|
||||
assert debug["table_events"] == table_events
|
||||
|
||||
|
||||
def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None:
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=None,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert debug["image_events"] == []
|
||||
assert debug["table_events"] == []
|
||||
@@ -0,0 +1,89 @@
|
||||
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
|
||||
|
||||
Stage 2 contract (unit u11):
|
||||
- multi-role zone feasible (deficit role + surplus role in the same zone)
|
||||
- single-role zone infeasible reason (no peer to donate surplus)
|
||||
|
||||
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
|
||||
depends on whether deficit roles can be covered by surplus roles within the
|
||||
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
|
||||
role_heights_before / role_heights_after and surfaces the
|
||||
'can_redistribute=False — single-role zone(s)' substring when redistribution
|
||||
is impossible. The apply helper must scope output to [data-role=...] only
|
||||
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_retry import (
|
||||
apply_cross_zone_redistribute_css,
|
||||
plan_cross_zone_redistribute,
|
||||
)
|
||||
|
||||
|
||||
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
|
||||
"""roles dict = {role: (allocated_px, shortfall_px)}.
|
||||
|
||||
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
|
||||
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
|
||||
"""
|
||||
return FitAnalysis(
|
||||
roles={
|
||||
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
|
||||
for name, (alloc, short) in roles.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_role_zone_feasible():
|
||||
"""Two roles in the same zone — deficit covered by surplus → feasible."""
|
||||
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["action"] == "cross_zone_redistribute"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["can_redistribute"] is True
|
||||
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
|
||||
after = plan["role_heights_after"]
|
||||
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
|
||||
assert after["top"] > 200.0
|
||||
assert after["bottom_l"] < 300.0
|
||||
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
|
||||
css = apply_cross_zone_redistribute_css(plan)
|
||||
assert '[data-role="top"]' in css
|
||||
assert '[data-role="bottom_l"]' in css
|
||||
# Scope lock — no global rules emitted.
|
||||
for forbidden in (":root", "body", ".slide", ".zone"):
|
||||
assert forbidden not in css
|
||||
|
||||
|
||||
def test_single_role_zone_infeasible_reason():
|
||||
"""Lone role in a zone has no peer to donate surplus → infeasible."""
|
||||
fit = _fit({"top": (200.0, 30.0)})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["can_redistribute"] is False
|
||||
reason = plan["failure_reason"]
|
||||
assert "single-role zone" in reason
|
||||
assert "can_redistribute=False" in reason
|
||||
# apply emits nothing when infeasible.
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
|
||||
|
||||
def test_empty_fit_analysis_infeasible():
|
||||
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["role_heights_before"] == {}
|
||||
assert "no roles" in plan["failure_reason"]
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""IMP-12 u14 — failure_router cascade tests.
|
||||
|
||||
Stage 2 contract (unit u14):
|
||||
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
|
||||
- 3 new failure types (cross_zone_redistribute_insufficient,
|
||||
glue_absorption_insufficient, font_step_insufficient) all route to
|
||||
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
|
||||
- rerender_still_fails preserved → frame_reselect
|
||||
|
||||
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
|
||||
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
|
||||
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
|
||||
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_failure_router import (
|
||||
NEXT_ACTION_BY_FAILURE,
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||
classify_retry_failure,
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
|
||||
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
|
||||
"""Stage 1 root cause — primary donor slack insufficient classifies as
|
||||
donor_slack_insufficient and routes onto the deterministic salvage cascade
|
||||
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {
|
||||
"feasible": False,
|
||||
"failure_reason": (
|
||||
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
|
||||
"< target_added_px 70px"
|
||||
),
|
||||
},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None
|
||||
assert fc["failure_type"] == "donor_slack_insufficient"
|
||||
|
||||
nr = route_retry_failure("donor_slack_insufficient")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
# enrichment composes both fields onto the trace
|
||||
enrich_retry_trace_with_failure_classification(trace)
|
||||
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
|
||||
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
|
||||
|
||||
|
||||
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
|
||||
"""no_donor_candidates is the second cascade entry — also onto
|
||||
cross_zone_redistribute per the locked mapping."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "no_donor_candidates"
|
||||
nr = route_retry_failure("no_donor_candidates")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
|
||||
types through the deterministic cascade: cross_zone → glue → font_step →
|
||||
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
|
||||
and via the classifier path."""
|
||||
# Direct mapping (u3 lock)
|
||||
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||
|
||||
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||
|
||||
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||
cases = [
|
||||
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
|
||||
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
|
||||
("font_step_compression", "font_step_insufficient", "layout_adjust"),
|
||||
]
|
||||
for action, expected_ftype, expected_next in cases:
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{"action": action, "passed": False, "failure_reason": "salvage failed"}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None, f"classifier returned None for action={action}"
|
||||
assert fc["failure_type"] == expected_ftype
|
||||
nr = route_retry_failure(fc["failure_type"])
|
||||
assert nr["next_proposed_action"] == expected_next
|
||||
|
||||
|
||||
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
|
||||
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": True},
|
||||
"rerender_attempted": True,
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "rerender_still_fails"
|
||||
nr = route_retry_failure("rerender_still_fails")
|
||||
assert nr["next_proposed_action"] == "frame_reselect"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""IMP-12 u13 — plan_font_step_compression tests.
|
||||
|
||||
Stage 2 contract (unit u13):
|
||||
- feasible case (15.2 → 13 closes excess)
|
||||
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
|
||||
- text_metrics missing → defensive infeasible reason
|
||||
|
||||
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||
Height savings per candidate font_size (Korean 1.6 line-height):
|
||||
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
|
||||
|
||||
Scope lock per feedback_phase_z_spacing_direction:
|
||||
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
|
||||
- No :root / body / .slide / .zone selectors permitted.
|
||||
"""
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_font_step_compression_css,
|
||||
plan_font_step_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_15_2_to_13_closes_excess() -> None:
|
||||
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
|
||||
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=20.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "font_step_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["current_font_px"] == 15.2
|
||||
assert plan["excess_after_glue_px"] == 20.0
|
||||
assert plan["available_lines"] == 10
|
||||
assert plan["chars_per_line"] == 40
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_font_px"] == 13.0
|
||||
assert "failure_reason" not in plan
|
||||
|
||||
css = apply_font_step_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "font-size: 13.0px" in css
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
|
||||
|
||||
|
||||
def test_infeasible_font_floor_exhausted() -> None:
|
||||
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
|
||||
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=200.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "font_step floor" in reason
|
||||
assert "8.0px" in reason
|
||||
assert "200.0px" in reason
|
||||
assert "FONT_SIZE_STEPS exhausted" in reason
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_text_metrics_missing_defensive_infeasible() -> None:
|
||||
"""available_lines=0 → guard fires before find_fitting_font_size;
|
||||
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=40.0,
|
||||
available_lines=0, chars_per_line=40, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert "text_metrics missing" in plan["failure_reason"]
|
||||
assert "available_lines/chars_per_line required" in plan["failure_reason"]
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""IMP-12 u12 — plan_glue_compression tests.
|
||||
|
||||
Stage 2 contract (unit u12):
|
||||
- feasible case asserts emitted CSS contains [data-zone-position=...]
|
||||
selector and NO global :root / body / .slide rule (scope lock)
|
||||
- insufficient case feasible=False with envelope reason
|
||||
|
||||
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
|
||||
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
|
||||
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
|
||||
+ inner_gap.shrink * block_count # 8 * n
|
||||
+ title_gap.shrink * block_count # 4 * n
|
||||
+ container_padding.shrink * 2 # 8 * 2
|
||||
|
||||
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
|
||||
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
|
||||
|
||||
CSS must be wrapped under [data-zone-position="<pos>"] only
|
||||
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_glue_compression_css,
|
||||
plan_glue_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_case_emits_zone_scoped_css():
|
||||
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
|
||||
|
||||
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
|
||||
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=40.0, block_count=3, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "glue_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["excess_px"] == 40.0
|
||||
assert plan["block_count"] == 3
|
||||
assert plan["absorption_max_px"] == 76.0
|
||||
overrides = plan["overrides"]
|
||||
assert overrides, "feasible plan must return non-empty overrides"
|
||||
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
|
||||
assert key in overrides, f"missing override key {key}"
|
||||
|
||||
css = apply_glue_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "--spacing-block:" in css
|
||||
assert "--spacing-inner:" in css
|
||||
assert "--container-padding:" in css
|
||||
# Scope lock — no global rules permitted.
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
|
||||
|
||||
|
||||
def test_insufficient_envelope_feasible_false_with_reason():
|
||||
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
|
||||
|
||||
failure_reason must surface the envelope shortage so the cascade router
|
||||
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=80.0, block_count=1, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["absorption_max_px"] == 28.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "glue envelope insufficient" in reason
|
||||
assert "excess_px 80" in reason
|
||||
assert "max absorption 28" in reason
|
||||
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_excess_non_positive_no_compression_needed():
|
||||
"""excess_px <= 0 → defensive infeasible (no compression required)."""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=0.0, block_count=3, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["overrides"] == {}
|
||||
assert plan["absorption_max_px"] == 0.0
|
||||
assert "no compression needed" in plan["failure_reason"]
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
@@ -0,0 +1,147 @@
|
||||
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
|
||||
|
||||
Stage 2 contract (unit u10):
|
||||
- single-donor sufficient (regression — backward compat preserved)
|
||||
- single insufficient + 2nd sufficient (multi-donor PASS path)
|
||||
- aggregate insufficient (multi-donor FAIL path)
|
||||
|
||||
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
|
||||
slack-desc aggregation across all eligible sibling zones. The plan dict
|
||||
now carries donors_used / aggregate_slack_used / aggregate_slack_available
|
||||
while preserving donor_zone_position + donor_reduced_px for the failure
|
||||
classifier substrings (router still keys off primary donor name).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {"router_active": True}
|
||||
|
||||
|
||||
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||
return {
|
||||
"classifications": [
|
||||
{
|
||||
"proposed_action": "zone_ratio_retry",
|
||||
"zone_position": target_pos,
|
||||
"inputs": {"excess_y": excess_y},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _zone(position: str, height_px: int, min_height_px: int,
|
||||
fit_status: str | None = "ok") -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"height_px": height_px,
|
||||
"min_height_px": min_height_px,
|
||||
"composition_rationale": {
|
||||
"capacity_fit": {"fit_status": fit_status},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overflow_clean(donor_positions: list[str]) -> dict:
|
||||
return {
|
||||
"zones": [
|
||||
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||
for p in donor_positions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_single_donor_sufficient_regression():
|
||||
"""One donor with abundant slack. Plan must remain feasible and the
|
||||
legacy donor_zone_position / donor_reduced_px fields must reflect the
|
||||
primary donor (router classifier substring stability)."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=200, min_height_px=180),
|
||||
_zone("bottom", height_px=400, min_height_px=200), # slack=200
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["bottom"]),
|
||||
fit_classification=_classification("top", excess_y=20.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["feasible"] is True
|
||||
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
|
||||
assert plan["target_added_px"] == 24
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_reduced_px"] == 24
|
||||
assert plan["donors_used"] == [
|
||||
{"position": "bottom", "reduced_px": 24,
|
||||
"slack_before": 200, "slack_after": 176}
|
||||
]
|
||||
assert plan["aggregate_slack_used"] == 24
|
||||
assert plan["aggregate_slack_available"] == 200
|
||||
assert plan["zones_after"]["top"] == 224
|
||||
assert plan["zones_after"]["bottom"] == 376
|
||||
|
||||
|
||||
def test_multi_donor_pass_primary_insufficient_secondary_covers():
|
||||
"""Primary donor alone has insufficient slack but primary + secondary
|
||||
aggregate covers target_added_px. Multi-donor greedy aggregation must
|
||||
split the deficit across both donors in slack-desc order."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=250, min_height_px=200), # slack=50
|
||||
_zone("bottom", height_px=240, min_height_px=200), # slack=40
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
|
||||
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_added_px"] == 70
|
||||
assert plan["aggregate_slack_available"] == 90
|
||||
assert plan["aggregate_slack_used"] == 70
|
||||
assert plan["donor_zone_position"] == "middle" # primary
|
||||
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
|
||||
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
|
||||
assert plan["donors_used"][0]["reduced_px"] == 50
|
||||
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
|
||||
assert plan["zones_after"]["top"] == 370
|
||||
assert plan["zones_after"]["middle"] == 200
|
||||
assert plan["zones_after"]["bottom"] == 220
|
||||
|
||||
|
||||
def test_multi_donor_fail_aggregate_insufficient():
|
||||
"""All donors combined still cannot cover target_added_px. Plan must
|
||||
be feasible=False with primary-donor substring preserved so the
|
||||
failure_router classifier still routes through donor_slack_insufficient."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=210, min_height_px=200), # slack=10
|
||||
_zone("bottom", height_px=215, min_height_px=200), # slack=15
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px=70, aggregate=25 → fail
|
||||
assert plan["feasible"] is False
|
||||
assert plan["aggregate_slack_available"] == 25
|
||||
assert plan["aggregate_slack_used"] == 0
|
||||
assert plan["donors_used"] == []
|
||||
# Primary = highest-slack donor = bottom (15)
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_max_slack"] == 15
|
||||
# Classifier substring stability: "donor", "slack", and "<" still present
|
||||
reason = plan["failure_reason"]
|
||||
assert "donor" in reason
|
||||
assert "slack" in reason
|
||||
assert "<" in reason
|
||||
# zones unchanged on fail (revert-friendly)
|
||||
assert plan["zones_after"]["top"] == 300
|
||||
assert plan["zones_after"]["middle"] == 210
|
||||
assert plan["zones_after"]["bottom"] == 215
|
||||
@@ -0,0 +1,196 @@
|
||||
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
|
||||
|
||||
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
|
||||
|
||||
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
|
||||
reason, ``passed=True``.
|
||||
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
|
||||
fail reason includes ``image aspect mismatch in zone--primary:``,
|
||||
``passed=False``.
|
||||
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
|
||||
``.slide``) → event reports ``zone_position == "unknown"``.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline's order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
|
||||
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
|
||||
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
IMAGE_ASPECT_DELTA_TOL,
|
||||
PROJECT_ROOT,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
|
||||
machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
|
||||
img = PIL_Image.new("RGB", (width, height), colour)
|
||||
img.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _find_event(events, src_basename: str) -> dict:
|
||||
for ev in events:
|
||||
if Path(ev.get("src", "")).name == src_basename:
|
||||
return ev
|
||||
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_image_no_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
|
||||
png = _write_png(tmp_path / "ok.png", 200, 100)
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
|
||||
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="ok.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert result.get("image_events"), "image_events must be populated"
|
||||
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "primary"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
|
||||
assert result["passed"] is True, result.get("fail_reasons")
|
||||
|
||||
|
||||
def test_image_forced_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
|
||||
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
|
||||
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="bad.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
|
||||
msg = image_fails[0]
|
||||
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
|
||||
assert "natural=2.000" in msg and "rendered=1.000" in msg
|
||||
assert f"src={png.name}" in msg or png.name in msg
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
|
||||
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
|
||||
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
|
||||
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
html_path = _write_slide_html(tmp_path, body, name="loose.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "unknown"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == []
|
||||
@@ -0,0 +1,334 @@
|
||||
"""IMP-15 실행-2 (Gitea issue #46) — Step 14 table_self_overflow detection.
|
||||
|
||||
Tests Selenium-driven ``<table>`` self-overflow measurement and element-identity
|
||||
wrapper dedup added to ``run_overflow_check``:
|
||||
|
||||
* Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper ancestor →
|
||||
``table_events`` entry reports ``wrapper_clipped_index = None`` and an
|
||||
``excess_*`` exceeding ``TABLE_SCROLL_TOL_PX``; Python aggregation then emits
|
||||
a ``table self-overflow`` fail_reason and flips ``result["passed"] = False``.
|
||||
* Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper. The wrapper itself
|
||||
self-overflows (registers in ``clippedWrapperMap``) and the inner table also
|
||||
self-overflows. Asserts dedup is honored: the table's ``wrapper_clipped_index``
|
||||
resolves to the wrapper's map index (non-null) so the Python aggregation MUST
|
||||
NOT emit a ``table self-overflow`` fail_reason — only the wrapper's pre-existing
|
||||
``inner clipped`` fail line remains.
|
||||
|
||||
* Fixture F — two wrappers W1 / W2 share identical className ``f13b-cell``. W1
|
||||
contains an overflowing inline-block child (no ``<table>``) → W1 self-overflows
|
||||
and registers in ``clippedWrapperMap`` (emits ``inner clipped``). W2 contains
|
||||
only a self-overflowing ``<table>``; W2's own scrollWidth equals its clientWidth
|
||||
(the table's ``overflow:hidden`` keeps W2 itself uncliped). The element-identity
|
||||
ancestor walk MUST resolve the W2 table's ``wrapper_clipped_index`` to ``None``
|
||||
(W2 ≠ W1 by DOM reference, despite identical class string). A class-string
|
||||
lookup would have falsely resolved the W2 table → W1 and suppressed the fail —
|
||||
the test thereby proves ``Map<Element, int>`` distinguishes by node identity.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH → Selenium Manager). When no driver
|
||||
is resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1``
|
||||
the tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
PROJECT_ROOT,
|
||||
TABLE_SCROLL_TOL_PX,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or ``PHASE_Z_REQUIRE_SELENIUM=1`` produces spurious strict-XPASS failures
|
||||
on machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML fixture helpers ────────────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fixture_d_standalone_table_overflow(tmp_path: Path) -> None:
|
||||
"""Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper.
|
||||
|
||||
The table is forced into block layout with a fixed clientWidth (100px) and
|
||||
``overflow: hidden``; the inner cell is 600px wide with ``white-space:nowrap``,
|
||||
so the table's scrollWidth exceeds clientWidth by well over ``TABLE_SCROLL_TOL_PX``.
|
||||
No ancestor carries an ``f13b/f29b/f16b`` class, so the element-identity walk
|
||||
must report ``wrapper_clipped_index = None``. Python aggregation then emits a
|
||||
``table self-overflow`` fail_reason and flips ``result["passed"]`` to ``False``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table">'
|
||||
'<table style="display:block; width:100px; height:30px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:600px; white-space:nowrap;">'
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_d.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert "table_events" in result, "run_overflow_check must expose table_events"
|
||||
table_events = result["table_events"]
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
assert ev["zone_position"] == "primary", ev
|
||||
assert ev["zone_template_id"] == "t_table", ev
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"standalone table must have null wrapper_clipped_index; got {ev['wrapper_clipped_index']}"
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"expected excess_x > {TABLE_SCROLL_TOL_PX}; got {ev['excess_x']} "
|
||||
f"(clientWidth={ev['clientWidth']}, scrollWidth={ev['scrollWidth']})"
|
||||
)
|
||||
|
||||
# Python aggregation: emitted fail_reason + passed flipped to False.
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one 'table self-overflow' fail_reason; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
assert result["passed"] is False, (
|
||||
f"table self-overflow must flip passed=False; got result={result}"
|
||||
)
|
||||
|
||||
|
||||
def test_fixture_e_table_in_clipped_wrapper_dedup(tmp_path: Path) -> None:
|
||||
"""Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper (dedup honored).
|
||||
|
||||
The wrapper (clientWidth=300, ``overflow:hidden``) contains a ``display:block``
|
||||
table forced to width=500px → wrapper.scrollWidth (≈500) − clientWidth (300) > 5px,
|
||||
so the wrapper is registered in ``clippedWrapperMap`` (emits ``inner clipped`` fail).
|
||||
The inner table is itself self-overflowing (clientWidth=500, content nowrap-cell
|
||||
width=900 → scrollWidth ≈ 900). The element-identity ancestor walk MUST resolve
|
||||
the table's ``wrapper_clipped_index`` to the wrapper's integer map index, and the
|
||||
Python aggregation MUST then SKIP emitting a ``table self-overflow`` fail_reason
|
||||
(the clipped wrapper already accounts for this).
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table_wrap">'
|
||||
'<div class="f13b-cell" style="width:300px; height:60px; overflow:hidden; '
|
||||
'box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_e.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Dedup signal: ancestor walk must hit the f13b wrapper via Map.has(node).
|
||||
assert ev["wrapper_clipped_index"] is not None, (
|
||||
f"table inside clipped wrapper must inherit wrapper index; got ev={ev}"
|
||||
)
|
||||
assert isinstance(ev["wrapper_clipped_index"], int), ev
|
||||
# The inner table is itself overflowing — proves the dedup is the only thing
|
||||
# suppressing the table_self_overflow fail (not absence of overflow).
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"inner table must be self-overflowing for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert table_fails == [], (
|
||||
f"dedup must suppress table self-overflow fail when wrapper is clipped; "
|
||||
f"got table_fails={table_fails} fail_reasons={fail_reasons}"
|
||||
)
|
||||
# Wrapper's clipped_inner fail line must still be present.
|
||||
clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(clipped_fails) >= 1, (
|
||||
f"wrapper clipped_inner fail must remain; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert result["passed"] is False, result
|
||||
|
||||
|
||||
def test_fixture_f_two_same_class_wrappers_element_identity(tmp_path: Path) -> None:
|
||||
"""Fixture F (F1 acceptance) — two same-class wrappers, element-identity dedup.
|
||||
|
||||
W1 and W2 share the identical className ``f13b-cell``. W1 (clientWidth=300,
|
||||
``overflow:hidden``) contains an inline-block ``<div>`` of width 600px →
|
||||
W1.scrollWidth − clientWidth ≈ 300 > 5; W1 is registered in
|
||||
``clippedWrapperMap`` and emits an ``inner clipped`` fail line. W2
|
||||
(clientWidth=600, ``overflow:hidden``) contains a 500px-wide block-display
|
||||
``<table>`` (matching the Fixture E table shape so the table is itself
|
||||
self-overflowing with excess_x > 5). W2's clientWidth (600) is larger than
|
||||
the table's outer width (500), so W2's own scrollWidth ≈ 500 < clientWidth
|
||||
and W2 is NOT registered in ``clippedWrapperMap``.
|
||||
|
||||
The element-identity ancestor walk in the pipeline (L2298–L2304) walks from
|
||||
the W2 table upward via ``parentElement`` and queries
|
||||
``clippedWrapperMap.has(node)`` — keyed by DOM node, NOT className. W2 is
|
||||
a different ``Element`` reference from W1 despite identical class string,
|
||||
so the lookup returns false at W2 and the walk terminates at ``.slide`` with
|
||||
``wrapper_clipped_index = null``. A class-substring keyed map (the F1
|
||||
regression scenario described in issue #46) would have resolved any
|
||||
``[class*="f13b"]`` ancestor of the W2 table → W1's index and falsely
|
||||
suppressed the W2 table_self_overflow fail.
|
||||
|
||||
Asserts:
|
||||
* Exactly ONE ``inner clipped`` fail line (for W1) — proves W1 is in the map.
|
||||
* Exactly ONE ``table self-overflow`` fail line (for W2's table) — proves
|
||||
the W2 table is NOT suppressed by W1's identical class string.
|
||||
* W2 table's ``table_events`` entry reports ``wrapper_clipped_index = None``
|
||||
(element-identity contract) and ``excess_x > TABLE_SCROLL_TOL_PX``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" '
|
||||
'data-template-id="t_table_same_class">'
|
||||
# W1 — same className, overflowing non-table child.
|
||||
'<div class="f13b-cell" id="w1" style="width:300px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative; '
|
||||
'margin-bottom:8px;">'
|
||||
'<div style="display:inline-block; width:600px; white-space:nowrap;">'
|
||||
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
'</div>'
|
||||
'</div>'
|
||||
# W2 — same className, NOT clipped (W2.clientWidth=600 > table.outer=500),
|
||||
# but the inner table itself self-overflows (table width=500, td width=900).
|
||||
'<div class="f13b-cell" id="w2" style="width:600px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; '
|
||||
'overflow:hidden; box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_f.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
|
||||
# Exactly one table_events entry (the W2 table — W1 has no <table>).
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Element-identity contract: W2 ≠ W1, so the ancestor walk MUST NOT inherit
|
||||
# W1's wrapper index merely because W2 shares W1's class string.
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"W2 (not itself clipped) must NOT inherit W1's index via class string; "
|
||||
f"got wrapper_clipped_index={ev['wrapper_clipped_index']}. "
|
||||
"This is the F1 regression — a class-substring map would have failed here."
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"W2's inner table must self-overflow for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
|
||||
# W1: inner clipped fail emitted (W1 is in clippedWrapperMap, has overflowing inner div).
|
||||
w1_clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(w1_clipped_fails) == 1, (
|
||||
f"expected exactly one W1 'inner clipped' fail; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
|
||||
# W2: table self-overflow fail emitted because element-identity dedup correctly
|
||||
# reports wrapper_clipped_index=None for the W2 table (W2 ≠ W1 by DOM ref).
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one W2 'table self-overflow' fail (element-identity dedup); "
|
||||
f"got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
|
||||
assert result["passed"] is False, result
|
||||
@@ -0,0 +1,248 @@
|
||||
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
|
||||
|
||||
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
|
||||
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
|
||||
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
|
||||
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
|
||||
|
||||
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
|
||||
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
|
||||
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
|
||||
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
|
||||
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz_pipeline
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_pipeline import _attempt_salvage_chain
|
||||
|
||||
|
||||
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tmp(tmp_path_factory):
|
||||
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
|
||||
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
|
||||
cross-drive system tmp path (pytest's default tmp_path is under
|
||||
%LOCALAPPDATA% on Windows, which lives on a different drive from
|
||||
the project root in this repo)."""
|
||||
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
|
||||
try:
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
_LAYOUT_CSS_GATE_PASS = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [300, 290],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.508, 0.491],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_render(monkeypatch):
|
||||
"""Stub render_slide → deterministic HTML envelope so the cascade does not
|
||||
need real Jinja2 templates. Returns a counter so tests can assert how many
|
||||
times it was invoked (one per CSS-feasible cascade step)."""
|
||||
counter = {"n": 0}
|
||||
|
||||
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||
counter["n"] += 1
|
||||
return (
|
||||
f"<html><head><meta charset='utf-8'></head>"
|
||||
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||
return counter
|
||||
|
||||
|
||||
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
|
||||
initial_failure_type: str = "donor_slack_insufficient") -> dict:
|
||||
return {
|
||||
"run_dir": run_dir,
|
||||
"out_path": out_path,
|
||||
"slide_title": "u15-test",
|
||||
"slide_footer": None,
|
||||
"zones_data": [],
|
||||
"layout_preset": "horizontal-2",
|
||||
"layout_css": _LAYOUT_CSS_GATE_PASS,
|
||||
"cascade_inputs": cascade_inputs,
|
||||
"initial_failure_type": initial_failure_type,
|
||||
"gap_px": 14,
|
||||
}
|
||||
|
||||
|
||||
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
|
||||
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
|
||||
passed=True → out_path overwritten with the cross_zone candidate HTML and
|
||||
salvage_passed=True after the very first cascade iteration."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
|
||||
})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 30.0, "excess_after_glue_px": 30.0,
|
||||
"block_count": 3, "zone_position": "top",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
_patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "cross_zone_redistribute"
|
||||
assert step0["passed"] is True
|
||||
assert step0["plan"]["feasible"] is True
|
||||
assert step0["css_override"] and '[data-role=' in step0["css_override"]
|
||||
# out_path was overwritten with the salvage candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
assert "u15-test" in promoted
|
||||
|
||||
|
||||
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
|
||||
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
|
||||
emitted + run_overflow_check passes → out_path overwritten with the glue
|
||||
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
|
||||
records the infeasible cross_zone attempt."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
|
||||
# (peer required, none present).
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||
"block_count": 3, "zone_position": "bottom_l",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
|
||||
# feasible → exactly one rerender + overflow call → return passed=True.
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 2
|
||||
|
||||
s0 = trace["salvage_steps"][0]
|
||||
assert s0["action"] == "cross_zone_redistribute"
|
||||
assert s0["passed"] is False
|
||||
assert s0["plan"]["feasible"] is False
|
||||
assert s0["css_override"] is None
|
||||
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
|
||||
|
||||
s1 = trace["salvage_steps"][1]
|
||||
assert s1["action"] == "glue_compression"
|
||||
assert s1["passed"] is True
|
||||
assert s1["plan"]["feasible"] is True
|
||||
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
|
||||
# render_slide was invoked exactly once (only the glue branch emitted CSS).
|
||||
assert render_counter["n"] == 1
|
||||
# out_path was overwritten with the glue candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
|
||||
|
||||
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||
salvage_steps has three failed entries, and out_path is unchanged
|
||||
(original final.html intact — (b)-revert preserved)."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
cascade_inputs = {
|
||||
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
|
||||
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
|
||||
"fit_analysis": None,
|
||||
"containers": {},
|
||||
"min_margin_px": 10,
|
||||
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
|
||||
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||
"block_count": 1, "zone_position": "top",
|
||||
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
|
||||
# → find_fitting_font_size returns None → feasible=False.
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# Guard: if run_overflow_check is ever called, the test fails loudly.
|
||||
def _must_not_call(_p): # pragma: no cover — intentional sentinel
|
||||
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
|
||||
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 3
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == [
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
]
|
||||
for step in trace["salvage_steps"]:
|
||||
assert step["passed"] is False
|
||||
assert step["css_override"] is None
|
||||
assert step["failure_reason"]
|
||||
# No CSS emitted anywhere → no render_slide calls either.
|
||||
assert render_counter["n"] == 0
|
||||
# (b) revert: out_path is untouched.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""IMP-15 실행-3 (Gitea issue #47) — classifier consumer pure-dict tests.
|
||||
|
||||
`classify_visual_runtime_check` was widened to consume the new
|
||||
``image_events[]`` / ``table_events[]`` arrays produced by ``run_overflow_check``
|
||||
(IMP-15 실행-1/2). The consumer must:
|
||||
|
||||
* emit ``image_aspect_mismatch`` when ``|delta| > IMAGE_ASPECT_DELTA_TOL`` and
|
||||
skip when ``delta is None`` or ``|delta| <= IMAGE_ASPECT_DELTA_TOL``;
|
||||
* emit ``tabular_overflow`` when a table self-overflows beyond
|
||||
``TABLE_SCROLL_TOL_PX`` and ``wrapper_clipped_index is None`` — and dedupe
|
||||
when the table sits under a wrapper already on the clipped-wrapper map
|
||||
(``wrapper_clipped_index`` non-null);
|
||||
* flip ``visual_check_passed`` to False whenever any classification fires, even
|
||||
if zone-level overflow was clean (``overflow["passed"]=True``).
|
||||
|
||||
All four cases are pure-dict — no Selenium / chromedriver dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_classifier import classify_visual_runtime_check
|
||||
from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
|
||||
|
||||
|
||||
def _base_overflow(**overrides) -> dict:
|
||||
"""Minimal clean overflow result; tests overlay image/table events."""
|
||||
base = {
|
||||
"passed": True,
|
||||
"slide": {"overflowed": False},
|
||||
"slide_body": {"overflowed": False},
|
||||
"zones": [],
|
||||
"image_events": [],
|
||||
"table_events": [],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ─── image_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_emits_classification():
|
||||
"""|delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit + flip visual_check_passed."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL + 0.05
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["image_aspect_mismatch"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "image_aspect_mismatch"
|
||||
assert cls["source"] == "image_event"
|
||||
assert cls["zone_position"] == "top"
|
||||
assert cls["delta"] == delta
|
||||
|
||||
|
||||
def test_image_aspect_delta_below_tol_no_classification():
|
||||
"""|delta| <= IMAGE_ASPECT_DELTA_TOL ⇒ skip (no false positive)."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL / 2.0
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
|
||||
|
||||
# ─── table_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_standalone_table_overflow_emits_classification():
|
||||
"""wrapper_clipped_index=None AND excess > TOL ⇒ emit tabular_overflow."""
|
||||
excess = TABLE_SCROLL_TOL_PX + 10
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": None,
|
||||
"excess_x": 0,
|
||||
"excess_y": excess,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["tabular_overflow"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "tabular_overflow"
|
||||
assert cls["source"] == "table_event"
|
||||
assert cls["zone_position"] == "bottom_l"
|
||||
assert cls["excess_y"] == excess
|
||||
|
||||
|
||||
def test_table_dedup_when_wrapper_clipped():
|
||||
"""wrapper_clipped_index non-null ⇒ skip (dedupe with clipped_inner cascade)."""
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": 0,
|
||||
"excess_x": 0,
|
||||
"excess_y": TABLE_SCROLL_TOL_PX + 50,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
@@ -0,0 +1,59 @@
|
||||
"""u1 — VerificationResult dataclass surface (IMP-16-U1).
|
||||
|
||||
Locks the Phase Z verification utility module anchor and the
|
||||
VerificationResult shape so downstream units (u2~u10) can rely on it
|
||||
without importing src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_module_importable_without_content_verifier():
|
||||
mod = importlib.import_module("src.phase_z2_verification_utils")
|
||||
tree = ast.parse(open(mod.__file__, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert "content_verifier" not in alias.name, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
assert node.module is None or "content_verifier" not in node.module, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
|
||||
|
||||
def test_verification_result_defaults():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
r = VerificationResult(passed=True, area_name="zone_test")
|
||||
assert r.passed is True
|
||||
assert r.area_name == "zone_test"
|
||||
assert r.checks == {}
|
||||
assert r.score == 0.0
|
||||
assert r.errors == []
|
||||
assert r.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_independent_default_collections():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
a = VerificationResult(passed=False, area_name="a")
|
||||
b = VerificationResult(passed=False, area_name="b")
|
||||
a.checks["x"] = True
|
||||
a.errors.append("e")
|
||||
a.warnings.append("w")
|
||||
assert b.checks == {} and b.errors == [] and b.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_required_fields():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
VerificationResult() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""u2 — pure HTML text extraction surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic visible-text extraction contract:
|
||||
- <style> / <script> contents are excluded.
|
||||
- Whitespace-only chunks are dropped; surviving chunks are stripped.
|
||||
- Order of visible-text fragments is preserved.
|
||||
- No import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_extract_plain_text_fragments_in_order():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>first</p><p>second</p><p>third</p>"
|
||||
assert extract_text_from_html(html) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_extract_skips_style_and_script_bodies():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = (
|
||||
"<html><head>"
|
||||
"<style>body { color: red; } .x { font-size: 12px; }</style>"
|
||||
"<script>var keep_out = 1;</script>"
|
||||
"</head><body><p>visible</p></body></html>"
|
||||
)
|
||||
out = extract_text_from_html(html)
|
||||
assert "visible" in out
|
||||
joined = " ".join(out)
|
||||
assert "color: red" not in joined
|
||||
assert "keep_out" not in joined
|
||||
|
||||
|
||||
def test_extract_drops_whitespace_only_chunks_and_strips_survivors():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<div> \n\n </div><div> hello </div><span> world\t</span>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["hello", "world"]
|
||||
|
||||
|
||||
def test_extract_preserves_korean_and_inline_markup_text():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>설계 <strong>방식</strong>의 왜곡</p>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["설계", "방식", "의 왜곡"]
|
||||
|
||||
|
||||
def test_extract_empty_input_returns_empty_list():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
assert extract_text_from_html("") == []
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for IMP-16-U1 unit u10: sample-backed smoke without pipeline import.
|
||||
|
||||
End-to-end smoke of the deterministic chain (extract_text_from_html ∘
|
||||
normalize_for_comparison ∘ split_into_sentences ∘ _sentence_matches_html
|
||||
→ verify_text_preservation / detect_invented_text) on a real
|
||||
``samples/mdx_batch`` MDX file. Per Stage 2 rationale: smoke coverage
|
||||
uses the sample but does NOT hardcode a sample-specific pass.
|
||||
|
||||
Also locks the AI-isolation contract for the verification axis: this
|
||||
test and the production module MUST NOT import orchestrator /
|
||||
phase_z2_pipeline / Phase Q content_verifier / Kei client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
detect_invented_text,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "02.mdx"
|
||||
_FORBIDDEN_IMPORT_ROOTS = (
|
||||
"orchestrator",
|
||||
"src.phase_z2_pipeline",
|
||||
"src.content_verifier",
|
||||
"src.kei_client",
|
||||
)
|
||||
|
||||
|
||||
def _module_imports(path: Path) -> set[str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.add(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module)
|
||||
return names
|
||||
|
||||
|
||||
def test_integration_sample_mdx_exists():
|
||||
# Smoke fixture availability gate; explicit so a missing sample
|
||||
# surfaces as a fixture problem, not a downstream assertion failure.
|
||||
assert _SAMPLE_MDX_PATH.exists(), f"sample missing: {_SAMPLE_MDX_PATH}"
|
||||
|
||||
|
||||
def test_integration_full_chain_runs_on_real_sample():
|
||||
# Locks API contract over the full chain on a real MDX: returns a
|
||||
# VerificationResult, area_name passthrough works, score within
|
||||
# [0.0, 1.0], and detect_invented_text returns a list. No assertion
|
||||
# is made about a specific score so the sample is not hardcoded as
|
||||
# the pipeline's pass rule (Stage 2 u10 rationale).
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.area_name == "smoke"
|
||||
assert 0.0 <= result.score <= 1.0
|
||||
assert isinstance(detect_invented_text(mdx, html), list)
|
||||
|
||||
|
||||
def test_integration_mirrored_html_passes_default_threshold():
|
||||
# When the HTML side mirrors the MDX text verbatim, the deterministic
|
||||
# preservation check must pass the Phase Q-default threshold (0.70).
|
||||
# This is the integration-level guarantee for the B-2 reverse path:
|
||||
# round-tripped HTML that preserves the MDX text must verify.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
def test_integration_fabricated_html_flags_invented_text():
|
||||
# Locks the hallucination-guard end-to-end: HTML text that has no
|
||||
# keyword anchor in the source MDX must be flagged. Synthetic
|
||||
# sentence chosen so its keywords (완전히, 만들어낸, 원본, 등장 …)
|
||||
# do not appear in samples/mdx_batch/02.mdx.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
fabricated_html = (
|
||||
"<p>완전히 새로 만들어낸 문장으로 원본에는 전혀 등장하지 않는 내용입니다.</p>"
|
||||
)
|
||||
invented = detect_invented_text(mdx, fabricated_html)
|
||||
assert isinstance(invented, list)
|
||||
assert len(invented) >= 1
|
||||
|
||||
|
||||
def test_integration_no_forbidden_imports():
|
||||
# AI-isolation + Phase Z scope-lock guard. Production module and
|
||||
# this test file must not import orchestrator / phase_z2_pipeline /
|
||||
# Phase Q content_verifier / Kei client. AST scan of the on-disk
|
||||
# source (not the imported module) so re-exports cannot mask a leak.
|
||||
for path in (
|
||||
_REPO_ROOT / "src" / "phase_z2_verification_utils.py",
|
||||
Path(__file__).resolve(),
|
||||
):
|
||||
modules = _module_imports(path)
|
||||
for module in modules:
|
||||
for forbidden in _FORBIDDEN_IMPORT_ROOTS:
|
||||
assert not (module == forbidden or module.startswith(forbidden + ".")), (
|
||||
f"{path.name} imports forbidden module: {module}"
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for IMP-16-U1 unit u9: ``detect_invented_text``.
|
||||
|
||||
Locks the Phase Z port of the deterministic hallucination guard
|
||||
(Phase Q reference: ``src/content_verifier.py:276-315``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), and u4 (extract_keywords). No Phase Q
|
||||
import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_INVENTED_TEXT_ALLOWED_LABELS,
|
||||
_INVENTED_TEXT_CSS_NUMBER_PATTERN,
|
||||
_INVENTED_TEXT_KEYWORD_THRESHOLD,
|
||||
_INVENTED_TEXT_MIN_LENGTH,
|
||||
_INVENTED_TEXT_TRUNCATE_LEN,
|
||||
detect_invented_text,
|
||||
)
|
||||
|
||||
|
||||
def test_detect_invented_text_constants_locked() -> None:
|
||||
"""Lock the five named module constants ported from Phase Q literals."""
|
||||
assert _INVENTED_TEXT_MIN_LENGTH == 15
|
||||
assert _INVENTED_TEXT_ALLOWED_LABELS == frozenset(
|
||||
{"용어 정의", "핵심 메시지", "상세 비교"}
|
||||
)
|
||||
assert _INVENTED_TEXT_CSS_NUMBER_PATTERN.pattern == r"^[\d\s.,%px#rgb()]+$"
|
||||
assert _INVENTED_TEXT_KEYWORD_THRESHOLD == 0.4
|
||||
assert _INVENTED_TEXT_TRUNCATE_LEN == 80
|
||||
|
||||
|
||||
def test_detect_invented_text_returns_empty_when_html_is_in_mdx() -> None:
|
||||
"""Text whose keywords fully appear in MDX is NOT flagged."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>원본 콘텐츠는 분석에 관한 것입니다.</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_flags_text_with_low_keyword_overlap() -> None:
|
||||
"""Text whose keywords do not appear in MDX is flagged as invented."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>완전히 다른 발명된 텍스트가 여기 있습니다 일반적이지 않은</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert "발명된" in result[0]
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_short_text() -> None:
|
||||
"""Text shorter than ``min_length`` is not even considered."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<p>짧은 텍스트</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_allowed_structural_labels() -> None:
|
||||
"""Allowed labels are skipped even when keyword overlap is zero.
|
||||
|
||||
Phase Q default ``min_length=15`` makes the allowed-label gate
|
||||
unreachable for the bundled labels (all < 15 chars). The Phase Z
|
||||
port preserves the gate verbatim — exercised here with
|
||||
``min_length=0`` so the structural-label short-circuit is
|
||||
actually observable.
|
||||
"""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<h2>용어 정의</h2><h2>핵심 메시지</h2><h2>상세 비교</h2>"
|
||||
assert detect_invented_text(mdx, html, min_length=0) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_css_number_pattern_fragments() -> None:
|
||||
"""CSS/numeric fragments (e.g. ``100px 200px 300px``) are skipped."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<style>.x { padding: 100px; }</style><div>100px 200px 300px</div>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_truncates_flagged_value_to_80_chars() -> None:
|
||||
"""A flagged fragment longer than 80 chars is truncated for reporting."""
|
||||
mdx = "원본 콘텐츠"
|
||||
invented = "발명" * 50
|
||||
html = f"<p>{invented}</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 80
|
||||
assert result[0] == invented[:80]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for Phase Z2 IMP-16-U1 unit u4: extract_keywords.
|
||||
|
||||
Locks the deterministic surface: 3+ character tokens on the Phase Z H3
|
||||
character class, longest-match trailing particle strip with a length>=2
|
||||
stem guard, and no Phase Q content_verifier import.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import _PARTICLES, extract_keywords
|
||||
|
||||
|
||||
def test_extract_keywords_drops_short_tokens() -> None:
|
||||
# "AI" (2 chars) and "X" (1 char) are dropped; "기술" (2 chars) is dropped too.
|
||||
# "데이터" (3 chars) survives; "분석함" (3 chars) survives.
|
||||
assert extract_keywords("AI 기술 X 데이터 분석함") == ["데이터", "분석함"]
|
||||
|
||||
|
||||
def test_extract_keywords_strips_trailing_particle_when_stem_ge_2() -> None:
|
||||
# "설계의" (3 chars) → particle "의" stripped, stem "설계" (2 chars) kept.
|
||||
# "방식은" → particle "은" stripped → "방식".
|
||||
assert extract_keywords("설계의 방식은") == ["설계", "방식"]
|
||||
|
||||
|
||||
def test_extract_keywords_keeps_token_when_stem_would_be_too_short() -> None:
|
||||
# "에서" guard: a 3-char token whose 2-char suffix is a particle
|
||||
# but whose stem (1 char) is < 2 must keep the original token.
|
||||
# "안에서" → suffix "에서" len 2, stem "안" len 1 → guard fires,
|
||||
# falls through, then next particle "서" is NOT in _PARTICLES,
|
||||
# so the whole token "안에서" remains.
|
||||
assert extract_keywords("안에서") == ["안에서"]
|
||||
|
||||
|
||||
def test_extract_keywords_longest_match_particle_wins() -> None:
|
||||
# "_PARTICLES" is sorted longest-first, so "에서" wins over "서"/"에".
|
||||
# "현장에서" → "에서" stripped → "현장".
|
||||
assert "에서" in _PARTICLES
|
||||
assert extract_keywords("현장에서") == ["현장"]
|
||||
|
||||
|
||||
def test_extract_keywords_tokenises_korean_alnum_and_parens() -> None:
|
||||
# The Phase Z H3 character class is [가-힣a-zA-Z0-9()]+.
|
||||
# "프로젝트(2024)" is one token; "Hello!" splits into "Hello" only.
|
||||
# Punctuation outside the class acts as a delimiter.
|
||||
result = extract_keywords("프로젝트(2024) Hello! World123")
|
||||
assert "프로젝트(2024)" in result
|
||||
assert "Hello" in result
|
||||
assert "World123" in result
|
||||
assert "!" not in "".join(result)
|
||||
|
||||
|
||||
def test_extract_keywords_empty_returns_empty() -> None:
|
||||
assert extract_keywords("") == []
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for IMP-16-U1 unit u7: ``_sentence_matches_html``.
|
||||
|
||||
Locks the Phase Z port of the deterministic per-sentence match
|
||||
helper (Phase Q reference: inline body of ``verify_text_preservation``
|
||||
at src/content_verifier.py:232-251). The helper is pure; no Phase Q
|
||||
import is exercised. Thresholds are locked as named constants so the
|
||||
0.6 / 0.65 surface cannot drift silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_SENTENCE_KEYWORD_MATCH_THRESHOLD,
|
||||
_SENTENCE_SEQUENCE_MATCH_THRESHOLD,
|
||||
_sentence_matches_html,
|
||||
)
|
||||
|
||||
|
||||
def test_match_helper_thresholds_locked():
|
||||
assert _SENTENCE_KEYWORD_MATCH_THRESHOLD == 0.6
|
||||
assert _SENTENCE_SEQUENCE_MATCH_THRESHOLD == 0.65
|
||||
|
||||
|
||||
def test_match_helper_returns_true_when_no_keywords():
|
||||
# "AI" tokenises to a single 2-char token which extract_keywords drops
|
||||
# (len < 3 gate). Empty keyword list -> helper returns True regardless
|
||||
# of HTML side. Phase Q parity: matched += 1; continue on empty keywords.
|
||||
assert _sentence_matches_html("AI", "", []) is True
|
||||
|
||||
|
||||
def test_match_helper_keyword_ratio_meets_threshold():
|
||||
# Sentence "데이터 분석의 핵심" -> keywords = ["데이터", "분석"]:
|
||||
# "데이터" (len 3, no particle ending) kept;
|
||||
# "분석의" (len 3, ends with "의", stem "분석" len 2) -> "분석" kept;
|
||||
# "핵심" (len 2 < 3) dropped.
|
||||
# Both keywords are substrings of the html_combined string, so
|
||||
# kw_ratio = 2 / 2 = 1.0 >= 0.6 -> True via keyword axis.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석의 핵심",
|
||||
"데이터 분석을 수행합니다",
|
||||
["데이터 분석을 수행합니다"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_sequence_ratio_fallback():
|
||||
# Sentence "데이터 분석" -> keywords = ["데이터"] (the 2-char "분석"
|
||||
# is dropped by the len<3 gate). "데이터" is NOT in html_combined,
|
||||
# so kw_ratio = 0. The SequenceMatcher fallback compares the
|
||||
# normalized sentence against each normalized html_text; the second
|
||||
# fragment matches verbatim, yielding ratio 1.0 >= 0.65 -> True.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz", "데이터 분석"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_below_both_thresholds_returns_false():
|
||||
# No keyword overlap and no high-similarity html fragment:
|
||||
# kw_ratio = 0, best SequenceMatcher ratio is far below 0.65.
|
||||
# Helper must return False so verify_text_preservation (u8)
|
||||
# records the sentence as missing.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz"],
|
||||
) is False
|
||||
@@ -0,0 +1,73 @@
|
||||
"""u5 — meta-line stripping surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic meta-line filter contract:
|
||||
- lines whose stripped form starts with any ``_META_PREFIXES`` entry
|
||||
are dropped (8 prefix surface);
|
||||
- lines containing any ``_META_INLINE_FRAGMENTS`` entry are dropped
|
||||
(3 inline fragment surface);
|
||||
- other lines pass through with original whitespace preserved;
|
||||
- empty input returns the empty string;
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_prefix_lines():
|
||||
from src.phase_z2_verification_utils import _META_PREFIXES, strip_meta_lines
|
||||
|
||||
# Exactly the 8-prefix Phase Z surface — locks both content and size.
|
||||
assert _META_PREFIXES == [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
text = "제목 라벨: 어떤 제목\n본문 한 줄\n표현 의도: 강조"
|
||||
assert strip_meta_lines(text) == "본문 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_matches_prefix_on_stripped_line():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Leading whitespace must not protect a meta-prefix line.
|
||||
text = " 제목 라벨: indented meta\n실제 본문"
|
||||
assert strip_meta_lines(text) == "실제 본문"
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_inline_fragment_lines():
|
||||
from src.phase_z2_verification_utils import (
|
||||
_META_INLINE_FRAGMENTS,
|
||||
strip_meta_lines,
|
||||
)
|
||||
|
||||
# Phase Z inline-fragment surface is exactly these three.
|
||||
assert _META_INLINE_FRAGMENTS == (
|
||||
"현상-문제 인과관계",
|
||||
"상위-하위 포함 관계",
|
||||
"독립적 나열",
|
||||
)
|
||||
text = (
|
||||
"구조: 현상-문제 인과관계 로 설계\n"
|
||||
"유형: 상위-하위 포함 관계\n"
|
||||
"패턴: 독립적 나열 형태\n"
|
||||
"그래서 결론은 한 줄"
|
||||
)
|
||||
assert strip_meta_lines(text) == "그래서 결론은 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_keeps_unrelated_lines_verbatim():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Non-meta lines must pass through with original whitespace preserved.
|
||||
text = " 본문 한 줄\n\n다른 줄"
|
||||
assert strip_meta_lines(text) == " 본문 한 줄\n\n다른 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_empty_input_returns_empty_string():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
assert strip_meta_lines("") == ""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""u3 — Korean text normalization surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic text-normalization contract:
|
||||
- whitespace runs collapse + strip;
|
||||
- bullet markers from the Phase Q surface set are removed;
|
||||
- the small HTML-entity set used by the reverse path is decoded;
|
||||
- a single trailing 개조식 ending is folded to its 서술형 form;
|
||||
- particle list is sorted longest-first (matching the Phase Q surface
|
||||
so downstream keyword stripping is greedy);
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_normalize_collapses_whitespace_and_strips():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison(" hello\n\n world\t") == "hello world"
|
||||
|
||||
|
||||
def test_normalize_removes_bullet_markers():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# Each marker from the Phase Q surface set must be stripped.
|
||||
for marker in ["•", "◦", "·", "-", "▪", "▸", "►"]:
|
||||
assert normalize_for_comparison(f"{marker} 항목") == "항목"
|
||||
|
||||
|
||||
def test_normalize_decodes_html_entities():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
text = "A & B <tag> 'q' "d""
|
||||
assert normalize_for_comparison(text) == "A & B <tag> 'q' \"d\""
|
||||
|
||||
|
||||
def test_normalize_folds_trailing_gaejo_endings():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison("적용함") == "적용한다"
|
||||
assert normalize_for_comparison("필요됨") == "필요된다"
|
||||
assert normalize_for_comparison("값이 있음") == "값이 있다"
|
||||
assert normalize_for_comparison("자료 없음") == "자료 없다"
|
||||
assert normalize_for_comparison("결과임") == "결과이다"
|
||||
assert normalize_for_comparison("적용되었음") == "적용되었다"
|
||||
assert normalize_for_comparison("적용되었음.") == "적용되었음." # trailing punct blocks fold
|
||||
|
||||
|
||||
def test_normalize_only_folds_one_ending_and_only_at_end():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# 'break' after first match: only the suffix is folded, mid-string '함' is left alone.
|
||||
assert normalize_for_comparison("함수를 적용함") == "함수를 적용한다"
|
||||
# No fold when the ending is not the last token.
|
||||
assert normalize_for_comparison("적용함 그리고 종료") == "적용함 그리고 종료"
|
||||
|
||||
|
||||
def test_particles_sorted_longest_first():
|
||||
from src.phase_z2_verification_utils import _PARTICLES
|
||||
|
||||
lengths = [len(p) for p in _PARTICLES]
|
||||
assert lengths == sorted(lengths, reverse=True)
|
||||
# Phase Q surface size guard (no values reused from REQUIRED_PATTERNS;
|
||||
# this is the Korean-locale particle inventory).
|
||||
assert "에서" in _PARTICLES and "는" in _PARTICLES
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for IMP-16-U1 unit u8: ``verify_text_preservation``.
|
||||
|
||||
Locks the Phase Z port of the deterministic text-preservation check
|
||||
(Phase Q reference: ``src/content_verifier.py:206-273``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), u6 (split_into_sentences), and u7
|
||||
(_sentence_matches_html). No Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
_MISSING_SENTENCE_REPORT_LIMIT,
|
||||
_MISSING_SENTENCE_TRUNCATE_LEN,
|
||||
_TEXT_PRESERVATION_DEFAULT_THRESHOLD,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_text_preservation_defaults_locked():
|
||||
# Locks the Phase Q caller convention: threshold default = 0.70,
|
||||
# missing-list report cap = 5, per-item truncate length = 60.
|
||||
assert _TEXT_PRESERVATION_DEFAULT_THRESHOLD == 0.70
|
||||
assert _MISSING_SENTENCE_REPORT_LIMIT == 5
|
||||
assert _MISSING_SENTENCE_TRUNCATE_LEN == 60
|
||||
|
||||
|
||||
def test_verify_text_preservation_empty_sentences_returns_passed():
|
||||
# MDX that reduces to zero sentences after split_into_sentences
|
||||
# (e.g. headers only) must return passed=True with score 1.0 and
|
||||
# an empty errors/warnings surface. Phase Q parity: early return
|
||||
# before any HTML extraction.
|
||||
result = verify_text_preservation("# header only", "<p>anything</p>", "core")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.passed is True
|
||||
assert result.area_name == "core"
|
||||
assert result.checks == {"text_preservation": True}
|
||||
assert result.score == 1.0
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_full_match_passes():
|
||||
# All MDX sentences preserved in HTML -> score 1.0, passed True,
|
||||
# no warnings (warnings only attached when score < 1.0), no errors.
|
||||
mdx = "데이터 분석은 핵심 과정입니다. 시각화로 의사 결정을 지원합니다."
|
||||
html = (
|
||||
"<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
"<p>시각화로 의사 결정을 지원합니다.</p>"
|
||||
)
|
||||
result = verify_text_preservation(mdx, html, "body")
|
||||
assert result.passed is True
|
||||
assert result.score == 1.0
|
||||
assert result.warnings == []
|
||||
assert result.errors == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_below_threshold_reports_errors():
|
||||
# Only one of two MDX sentences appears in the HTML -> score 0.5,
|
||||
# below default threshold 0.70 -> passed False, errors list opens
|
||||
# with the "누락 문장 (1/2):" header followed by quoted missing
|
||||
# sentences (truncation gate not crossed).
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.score == 0.5
|
||||
assert result.checks == {"text_preservation": False}
|
||||
assert result.errors[0] == "누락 문장 (1/2):"
|
||||
assert any("두 번째 문장" in line for line in result.errors[1:])
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
|
||||
|
||||
def test_verify_text_preservation_truncates_long_missing_sentence():
|
||||
# A missing sentence longer than 60 chars must be rendered with
|
||||
# the "...\"" tail. Phase Z surface lifts the 60 constant to a
|
||||
# named module value (_MISSING_SENTENCE_TRUNCATE_LEN) so the gate
|
||||
# is auditable.
|
||||
long_sentence = "엄청나게 긴 문장이 들어가서 절단 동작을 검증합니다." + ("끝" * 60)
|
||||
mdx = long_sentence + "."
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "footer", threshold=0.99)
|
||||
assert result.passed is False
|
||||
# Header + at least one missing-line entry; the entry must end with `..."`.
|
||||
assert len(result.errors) >= 2
|
||||
assert result.errors[-1].endswith("...\"")
|
||||
truncated_body = result.errors[-1].split('"', 2)[1].rstrip(".")
|
||||
assert len(truncated_body) == _MISSING_SENTENCE_TRUNCATE_LEN
|
||||
|
||||
|
||||
def test_verify_text_preservation_caps_missing_report_at_limit():
|
||||
# Generate seven MDX-only sentences with no HTML coverage.
|
||||
# passed=False, errors list = 1 header + at most 5 missing entries
|
||||
# (_MISSING_SENTENCE_REPORT_LIMIT). The header reports the true
|
||||
# missing/total counts even though only 5 are surfaced.
|
||||
mdx_lines = [f"전혀 다른 문맥의 문장 번호 {i} 입니다." for i in range(7)]
|
||||
mdx = "\n".join(mdx_lines)
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.errors[0] == "누락 문장 (7/7):"
|
||||
assert len(result.errors) == 1 + _MISSING_SENTENCE_REPORT_LIMIT
|
||||
|
||||
|
||||
def test_verify_text_preservation_custom_threshold_passes_at_50_percent():
|
||||
# Lowering the threshold to 0.50 makes a 50% preservation pass.
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core", threshold=0.50)
|
||||
assert result.passed is True
|
||||
assert result.score == 0.5
|
||||
# Score < 1.0 so the 보존율 warning is still attached for trace surface.
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for IMP-16-U1 unit u6: split_into_sentences.
|
||||
|
||||
Locks the Phase Z port of the H3 deterministic sentence-splitter
|
||||
surface (Phase Q reference: src/content_verifier.py:174-199). The
|
||||
function is deterministic, pure, and composes ``strip_meta_lines``;
|
||||
no Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_BULLET_MARKER_PATTERN,
|
||||
_MIN_SENTENCE_LEN,
|
||||
_SENTENCE_SPLIT_PATTERN,
|
||||
split_into_sentences,
|
||||
)
|
||||
|
||||
|
||||
def test_split_into_sentences_applies_strip_meta_lines_first():
|
||||
text = (
|
||||
"제목 라벨: 설계 방식의 왜곡\n"
|
||||
"본문 첫 문장입니다.\n"
|
||||
"본문 둘째 문장입니다."
|
||||
)
|
||||
result = split_into_sentences(text)
|
||||
assert result == ["본문 첫 문장입니다.", "본문 둘째 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_skips_empty_and_header_lines():
|
||||
text = "\n# 대목차\n## 소목차\n실제 본문 문장입니다.\n"
|
||||
assert split_into_sentences(text) == ["실제 본문 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_strips_numeric_and_punctuated_markers():
|
||||
assert _BULLET_MARKER_PATTERN.match("1. 첫 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("2) 둘째 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("-. 첫 항목입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("•. 둘째 항목입니다.")
|
||||
text = (
|
||||
"1. 첫 단계입니다.\n"
|
||||
"2) 둘째 단계입니다.\n"
|
||||
"-. 셋째 항목입니다."
|
||||
)
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 단계입니다.",
|
||||
"둘째 단계입니다.",
|
||||
"셋째 항목입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_keeps_bare_dash_bullet_unstripped():
|
||||
assert _BULLET_MARKER_PATTERN.match("- 항목 하나입니다.") is None
|
||||
text = "- 항목 하나입니다."
|
||||
assert split_into_sentences(text) == ["- 항목 하나입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_splits_on_period_boundary():
|
||||
assert _SENTENCE_SPLIT_PATTERN.pattern == r"(?<=\.)\s+"
|
||||
text = "첫 문장입니다. 둘째 문장입니다. 셋째 문장입니다."
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 문장입니다.",
|
||||
"둘째 문장입니다.",
|
||||
"셋째 문장입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_drops_parts_shorter_than_min_len():
|
||||
assert _MIN_SENTENCE_LEN == 5
|
||||
text = "OK. 충분히 긴 문장입니다."
|
||||
assert split_into_sentences(text) == ["충분히 긴 문장입니다."]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""IMP-14 A-4 — slide_base.html embedded_mode contract tests.
|
||||
|
||||
Asserts the three-valued enum (auto / embedded / standalone) round-trips
|
||||
through render_slide -> slide_base.html, that the additive html.embedded
|
||||
CSS reset and the auto-mode detection <script> are emitted under the
|
||||
correct modes, that the invalid-mode guard raises ValueError, and that
|
||||
Jinja2 rendering is byte-deterministic across calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _zone() -> dict:
|
||||
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _render(embedded_mode: str = "auto") -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[_zone()],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode=embedded_mode,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_script_present():
|
||||
html = _render("auto")
|
||||
assert "params.get('embedded')" in html
|
||||
assert "window.self !== window.top" in html
|
||||
assert "classList.add('embedded')" in html
|
||||
|
||||
|
||||
def test_css_rules_present():
|
||||
html = _render("auto")
|
||||
assert "html.embedded body" in html
|
||||
assert "html.embedded .slide" in html
|
||||
|
||||
|
||||
def test_embedded_mode_explicit():
|
||||
html = _render("embedded")
|
||||
assert '<html lang="ko" class="embedded">' in html
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_standalone_mode_explicit():
|
||||
html = _render("standalone")
|
||||
assert '<html lang="ko">' in html
|
||||
assert 'class="embedded"' not in html.split("</head>")[0]
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_deterministic():
|
||||
assert _render("embedded") == _render("embedded")
|
||||
assert _render("auto") == _render("auto")
|
||||
|
||||
|
||||
def test_invalid_mode_raises():
|
||||
with pytest.raises(ValueError, match="invalid embedded_mode"):
|
||||
_render("bogus")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Spec lint: PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 taxonomy must declare
|
||||
the `image_aspect_mismatch` row (IMP-15 실행-4, issue #48 u2).
|
||||
|
||||
The row encodes a post-render `fail_reasons` signal surfaced by Step 14
|
||||
visual_runtime_check, not a router-routed fit_classifier output. It is
|
||||
intentionally placed inside §3.1 to keep the taxonomy vocabulary aligned
|
||||
with the event streams now exposed at debug.json top level (u1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SPEC_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md"
|
||||
)
|
||||
|
||||
|
||||
def _read_spec_text() -> str:
|
||||
return SPEC_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_section_3_1(text: str) -> str:
|
||||
start_match = re.search(r"^###\s+3\.1\b", text, flags=re.MULTILINE)
|
||||
assert start_match, "§3.1 heading missing from spec"
|
||||
after_3_1 = text[start_match.end():]
|
||||
end_match = re.search(r"^###\s+3\.2\b", after_3_1, flags=re.MULTILINE)
|
||||
assert end_match, "§3.2 heading missing from spec"
|
||||
return after_3_1[: end_match.start()]
|
||||
|
||||
|
||||
def test_spec_section_3_1_contains_image_aspect_mismatch_row():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_pattern = re.compile(r"^\|\s*`image_aspect_mismatch`\s*\|", re.MULTILINE)
|
||||
matches = row_pattern.findall(section)
|
||||
assert len(matches) == 1, (
|
||||
"Expected exactly 1 `image_aspect_mismatch` row inside §3.1 taxonomy, "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_row_reflects_post_render_semantic():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_line = next(
|
||||
(
|
||||
line
|
||||
for line in section.splitlines()
|
||||
if line.lstrip().startswith("| `image_aspect_mismatch`")
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert row_line is not None, "image_aspect_mismatch row not found"
|
||||
assert "Post-render" in row_line or "post-render" in row_line, (
|
||||
"Row must mark the signal as post-render (Stage 1 guardrail)"
|
||||
)
|
||||
assert "fail_reasons" in row_line, (
|
||||
"Row must reference `fail_reasons` so the vocabulary mirrors the "
|
||||
"visual_runtime_check output"
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""IMP-13 u7 smoke — discovery, source invariants, dry-run, idempotency, manifest schema."""
|
||||
from __future__ import annotations
|
||||
import json, os, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_frame_previews.py"
|
||||
sys.path.insert(0, str(SCRIPT_PATH.parent))
|
||||
import generate_frame_previews as gfp # noqa: E402
|
||||
|
||||
def _fixture(root: Path) -> Path:
|
||||
blocks = root / "blocks"
|
||||
(blocks / "FRAME_A").mkdir(parents=True)
|
||||
(blocks / "FRAME_A" / "index.html").write_text("<html><body class=slide></body></html>", encoding="utf-8")
|
||||
(blocks / "FRAME_A" / "preview.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
(blocks / "FRAME_B").mkdir()
|
||||
(blocks / "ORPHAN").mkdir()
|
||||
(blocks / "ORPHAN" / "preview.png").write_bytes(b"x")
|
||||
return blocks
|
||||
|
||||
def test_discover_counts(tmp_path: Path) -> None:
|
||||
rows = gfp.discover(_fixture(tmp_path))
|
||||
assert [r.frame_id for r in rows] == ["FRAME_A", "FRAME_B", "ORPHAN"]
|
||||
assert sum(r.has_index for r in rows) == 1 and sum(r.has_preview for r in rows) == 2
|
||||
|
||||
def test_source_invariants() -> None:
|
||||
src = SCRIPT_PATH.read_text(encoding="utf-8")
|
||||
for t in ("anthropic", "openai", "jinja", "phase_z2", "slide_measurer"): assert t not in src, t
|
||||
for lit in ("1280", "720", "1400", "900"): assert not re.search(rf"(?<!\d){lit}(?!\d)", src), lit
|
||||
|
||||
def test_dry_run_prints_counts(tmp_path: Path, capsys) -> None:
|
||||
rc = gfp.main(["--blocks-dir", str(_fixture(tmp_path)), "--manifest", str(tmp_path / "m.json"), "--dry-run"])
|
||||
assert rc == 0 and "discovered: total=3 with_index_html=1 with_preview_png=2" in capsys.readouterr().out
|
||||
|
||||
def test_idempotency_unchanged(tmp_path: Path) -> None:
|
||||
row = gfp.discover(_fixture(tmp_path))[0]
|
||||
mt = row.index_html_path.stat().st_mtime
|
||||
os.utime(row.preview_png_path, (mt + 1, mt + 1))
|
||||
sha = gfp._sha256_file(row.index_html_path)
|
||||
assert gfp.is_unchanged(row, {"index_sha256": sha}) is True
|
||||
assert gfp.is_unchanged(row, {"index_sha256": "x"}) is False
|
||||
assert gfp.is_unchanged(row, None) is False
|
||||
|
||||
def test_manifest_schema(tmp_path: Path) -> None:
|
||||
blocks = tmp_path / "blocks"; (blocks / "F").mkdir(parents=True); (blocks / "F" / "preview.png").write_bytes(b"x")
|
||||
mf = tmp_path / "m.json"
|
||||
assert gfp.main(["--blocks-dir", str(blocks), "--manifest", str(mf)]) == 0
|
||||
data = json.loads(mf.read_text(encoding="utf-8"))
|
||||
assert set(data) >= {"schema", "generated_at", "blocks_dir", "summary", "frames"} and data["schema"] == 1
|
||||
assert set(data["summary"]) >= {"total", "renderable", "missing_index_html", "orphan", "rendered", "skipped_unchanged", "error"} and data["summary"]["orphan"] == 1 and data["frames"]["F"]["status"] == "orphan"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
|
||||
|
||||
u1 contract:
|
||||
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
|
||||
catalog `frame_contracts[template_id].visual_hints.min_height_px`
|
||||
(logical 1280×720 px), with `None` fallback when contract is unregistered.
|
||||
A single `get_contract(c.template_id)` lookup binds both
|
||||
`catalog_registered` and `min_height_px` (no double-lookup cost).
|
||||
|
||||
Production code = inline list builder in `run_phase_z2_mvp1`
|
||||
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
|
||||
the same source-string + catalog-shape guard pattern as the existing
|
||||
`test_step9_production_emits_candidate_evidence_and_alias` in
|
||||
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from src import phase_z2_pipeline
|
||||
from phase_z2_mapper import get_contract, load_frame_contracts
|
||||
|
||||
|
||||
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
|
||||
|
||||
|
||||
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
|
||||
"""Source guard — single get_contract bound to `_contract`, then both
|
||||
`catalog_registered` and `min_height_px` derived from that binding.
|
||||
`min_height_px` uses the `(_contract or {})` chain so unregistered
|
||||
contracts propagate `None` (frontend tolerates undefined).
|
||||
"""
|
||||
source = inspect.getsource(phase_z2_pipeline)
|
||||
|
||||
# u1 marker present (locates the builder)
|
||||
assert "IMP-11 D-2 (u1)" in source
|
||||
|
||||
# Single get_contract lookup bound to local var
|
||||
assert "_contract = get_contract(c.template_id)" in source
|
||||
|
||||
# catalog_registered reuses the local binding (no second lookup)
|
||||
assert '"catalog_registered": _contract is not None' in source
|
||||
|
||||
# min_height_px source = visual_hints chain; None when contract is None
|
||||
assert (
|
||||
'"min_height_px": (_contract or {})'
|
||||
'.get("visual_hints", {})'
|
||||
'.get("min_height_px")'
|
||||
) in source
|
||||
|
||||
# v4_all_judgments wires the new builder list
|
||||
assert '"v4_all_judgments": v4_all_judgments_list' in source
|
||||
|
||||
|
||||
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
|
||||
|
||||
|
||||
def test_v4_all_judgments_preserves_existing_fields():
|
||||
"""u1 is additive only — the existing 7 keys must remain in the per-entry
|
||||
dict alongside the new `min_height_px`.
|
||||
"""
|
||||
source = inspect.getsource(phase_z2_pipeline)
|
||||
|
||||
builder_start = source.find("IMP-11 D-2 (u1)")
|
||||
assert builder_start != -1
|
||||
builder_end = source.find("application_plan_units.append", builder_start)
|
||||
assert builder_end != -1
|
||||
builder = source[builder_start:builder_end]
|
||||
|
||||
for field in (
|
||||
'"template_id": c.template_id',
|
||||
'"frame_id": c.frame_id',
|
||||
'"frame_number": c.frame_number',
|
||||
'"v4_rank": c.v4_rank',
|
||||
'"confidence": c.confidence',
|
||||
'"label": c.label',
|
||||
'"catalog_registered": _contract is not None',
|
||||
'"min_height_px":',
|
||||
):
|
||||
assert field in builder, f"missing field in u1 builder: {field!r}"
|
||||
|
||||
|
||||
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
|
||||
|
||||
|
||||
def test_catalog_visual_hints_min_height_px_path_is_real():
|
||||
"""The source-string guard depends on the actual catalog shape having
|
||||
`visual_hints.min_height_px` as a positive int on registered contracts
|
||||
whose `visual_hints` block declares it. Verify against the real
|
||||
`frame_contracts.yaml` so a future catalog schema change cannot silently
|
||||
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
# Real registered template_ids that ship with visual_hints.min_height_px
|
||||
# (verified via load_frame_contracts() — see frame_contracts.yaml).
|
||||
sample_template_ids = (
|
||||
"three_parallel_requirements",
|
||||
"process_product_two_way",
|
||||
"construction_goals_three_circle_intersection",
|
||||
"bim_dx_comparison_table",
|
||||
)
|
||||
|
||||
found = 0
|
||||
for tid in sample_template_ids:
|
||||
contract = get_contract(tid)
|
||||
if contract is None:
|
||||
continue # tolerate catalog rename — at least one must remain
|
||||
# The exact .get chain used by the u1 builder
|
||||
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
assert isinstance(min_h, int), (
|
||||
f"{tid}: visual_hints.min_height_px must be int, "
|
||||
f"got {type(min_h).__name__}={min_h!r}"
|
||||
)
|
||||
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
|
||||
found += 1
|
||||
|
||||
assert found > 0, (
|
||||
"no sample registered contract present — catalog audit drift; "
|
||||
"update sample_template_ids to match current frame_contracts.yaml"
|
||||
)
|
||||
|
||||
|
||||
def test_registered_contract_without_min_height_px_propagates_none():
|
||||
"""Registered contract whose `visual_hints` block omits `min_height_px`
|
||||
(or sets it to `null`) must also propagate `None` through the u1 chain.
|
||||
Real example in current catalog: `bim_issues_quadrant_four`.
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
tid = "bim_issues_quadrant_four"
|
||||
contract = get_contract(tid)
|
||||
if contract is None:
|
||||
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
|
||||
pytest.skip(f"sample template {tid!r} no longer registered")
|
||||
|
||||
# Exact chain used by u1 builder
|
||||
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
assert min_h is None, (
|
||||
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
|
||||
f"got {min_h!r} — chain semantics changed"
|
||||
)
|
||||
# catalog_registered must still be True (additive, independent of value)
|
||||
assert (contract is not None) is True
|
||||
|
||||
|
||||
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
|
||||
|
||||
|
||||
def test_unregistered_template_id_propagates_none():
|
||||
"""When `get_contract(template_id)` returns `None`, the u1 chain
|
||||
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
|
||||
yield `None` (frontend tolerates undefined; no KeyError).
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
# Synthetic template_id guaranteed not to be in the catalog
|
||||
unregistered = "MOCK_template_unregistered_for_u1_test"
|
||||
assert get_contract(unregistered) is None, (
|
||||
"test precondition broken — synthetic template_id leaked into catalog"
|
||||
)
|
||||
|
||||
# Replicate the u1 chain exactly
|
||||
_contract = get_contract(unregistered)
|
||||
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
catalog_registered = _contract is not None
|
||||
|
||||
assert min_height is None
|
||||
assert catalog_registered is False
|
||||
Reference in New Issue
Block a user