Skill: ccmxigua/beamer-pipeline-public (version 0.3.1)

# Beamer Pipeline Public Convert academic papers or structured paper notes into a Chinese academic Beamer deliverable through the same seven-phase pipeline shape as the original `/beamer` shortcut, while keeping the public package free of private Telegram queue, worker, session, and local-path coupling. ## v0.3.0 - Removed all hardcoded `~/clawd/*` paths in artifact-search roots; replaced with `~/.openclaw/*` equivalents so the skill runs correctly on any OpenClaw installation. - Added `run_agent_role.js` with shared agent-role dispatch logic (programmer/reviewer/tester). - Added full repair/scanner script suite: equation coverage repair, instruction leak repair, notation coverage repair, strategy placeholder repair, equation coverage scanner, slide symbol scanner, and coverage status. - Enhanced `deck_language_render_guards.js` for output language constraints. - All 15 JS scripts pass `node -c` syntax verification. ## What this skill does This skill provides a publishable, local-first Beamer pipeline runner. It preserves the original seven-stage contract: 1. `analysis` / 分析: build `analysis.json` with paragraph, figure, table, equation, notation, and formal-statement ledgers. 2. `slides_outline_skeleton` / slides 大纲: build `slides.json` with roadmap, conclusion preview, body/appendix split, and slide skeletons. 3. `equation_coverage` / 公式覆盖: resolve `equation_coverage` to concrete slide/frame mappings. 4. `notation_consistency` / 记号一致性: resolve `notation_coverage`, first visible definitions, and cross-slide consistency. 5. `compile_and_structural_repair` / 编译与结构修复: produce `main.tex`, compile or repair `main.pdf`, and check structural alignment. 6. `review_and_auto_rework` / reviewer 审核与自动返工: convert reviewer failures into repair work and revalidate. 7. `final_acceptance_delivery` / 终验与交付: package final artifacts and require ready-for-review acceptance fields. The public runner creates phase prompts, phase logs, state files, workspace contracts, and local validation gates. Agent execution is intentionally pluggable via `--agent-cmd` so that ClawHub users can attach their own OpenClaw or LLM runtime without shipping Yilin's private Telegram worker/session implementation. ## Deliverable folder The pipeline writes one canonical output directory containing, as phases progress: - `analysis.json` - `slides.json` - `main.tex` - `main.pdf` when LaTeX compilation succeeds - `README.md` - `asset_manifest.json` - `figures/` - `beamer_task_contract.md` - `beamer_pipeline_state.json` - `beamer_phase_log.jsonl` - `phase_<n>_<name>_prompt.md` ## Inputs - A source document path, usually Markdown converted from a paper, or a paper-note file. - An output directory. - Optional execution flags for dry-run, single-phase execution, validation, asset preparation, LaTeX command, and agent command. ## CLI usage ```bash node scripts/run_beamer_public.js --input paper.md --out out --dry-run node scripts/run_beamer_public.js --input paper.md --out out --init-only node scripts/run_beamer_public.js --input paper.md --out out --phase 1 --agent-cmd 'your-agent --prompt {prompt}' node scripts/run_beamer_public.js --input paper.md --out out --validate-phase 5 ``` Supported options: - `--init-only`: prepare workspace, localized assets, and contract only. - `--dry-run`: print the seven-phase plan and required artifacts. - `--phase <1-7>`: run one phase. - `--validate-phase <2-6>`: validate existing artifacts for a phase gate. - `--agent-cmd <command>`: command used to produce/repair artifacts for each phase. - `--latex-cmd <command>`: LaTeX command for phase 5, default `LATEXMK_BIN` or `latexmk`. - `--skip-assets`: skip `prepare_task_assets.js`. `--agent-cmd` placeholders: - `{prompt}`: generated phase prompt file path, shell-quoted. - `{phase}`: phase index. - `{phaseName}`: phase name. - `{out}`: output directory, shell-quoted. - `{input}`: source file path, shell-quoted. ## Agent setup (required for default `--agent-cmd`) The default agent command uses `{role}` which maps to three agent IDs: | Phase | Agent ID | |-------|-------------| | 1-5 | programmer | | 6 | reviewer | | 7 | tester | These are **not** standard OpenClaw agent IDs — they must be configured in the user's `~/.openclaw/openclaw.json` under `agents.list`. A minimal configuration example: ```json { "agents": { "list": [ { "id": "main", ... }, { "id": "programmer", "name": "Programmer", "model": { "primary": "your-model" }, "workspace": "/path/to/workspace" }, { "id": "reviewer", "name": "Reviewer", "model": { "primary": "your-model" }, "workspace": "/path/to/workspace" }, { "id": "tester", "name": "Tester", "model": { "primary": "your-model" }, "workspace": "/path/to/workspace" } ] } } ``` ### Alternatives - **`--no-agent`**: skip all agent invocations and write prompt files only. Useful for inspection or manual execution. - **Custom `--agent-cmd`**: provide your own command, e.g. `--agent-cmd 'openclaw agent --agent main --message "$(cat {prompt})"'` to route all phases through a single agent. ## Quality contract A generated deck should: - Preserve all source figures, tables, subfigures, and appendix figures/tables when available. - Expand slide count as needed; any target slide count is a lower bound, not a cap. - Include a roadmap page and conclusion-preview page. - Use formal Chinese academic presentation style. - Separate formal statements from intuition when both are needed. - Give full faithful Chinese translations for crucial theorem/proposition/lemma/corollary/definition/assumption statements. - Keep visible slide text free of internal scaffold labels such as `core_message`, `source_paragraph_ids`, `paragraph_ledger`, `核心信息`, `来源段落`, `这页负责`, and `服务于未读论文听众`. - Track figure/table/equation/notation/formal-statement coverage explicitly. - Define symbols, abbreviations, operators, and hyperparameters on first visible use using source-grounded definitions. ## Included scripts ### Pipeline runner - `scripts/run_beamer_public.js`: public seven-phase runner with pluggable agent execution. - `scripts/run_agent_role.js`: shared agent-role logic for programmer/reviewer/tester phases. - `scripts/beamer_phase_blueprint.js`: seven-phase names, goals, required artifacts, and planned-coverage gates. - `scripts/prepare_task_assets.js`: localizes markdown image URLs into `figures/` and writes `asset_manifest.json`. - `scripts/beamer_acceptance_contract.js`: validates final structured Beamer acceptance fields. ### Repair scripts (auto-fix specific quality issues) - `scripts/deck_equation_coverage_repair.js`: repairs unresolved equation coverage entries. - `scripts/deck_instruction_leak_repair.js`: removes scaffold/internal instruction leakage from slide text. - `scripts/deck_notation_coverage_repair.js`: repairs missing or inconsistent notation coverage. - `scripts/deck_strategy_placeholder_repair.js`: replaces strategy placeholders with concrete content. ### Scanner scripts (detect quality issues) - `scripts/deck_equation_coverage_scanner.js`: scans for equation coverage gaps. - `scripts/slide_symbol_scanner.js`: scans for symbol definition/usage inconsistencies. - `scripts/coverage_status.js`: computes coverage status across equation/notation/formal-statement dimensions. ### Shared utilities - `scripts/deck_symbol_canonicalization.js`: shared symbol canonicalization helper. - `scripts/deck_language_render_guards.js`: render guard constraints for output language. - `scripts/slide_schema.js`: shared slide/equation block normalization helper. ## Dependencies Required: - Node.js 18+ or newer Optional: - A LaTeX distribution such as TeX Live or MacTeX - `latexmk` or `xelatex` for compiling `main.tex` to `main.pdf` - An OpenClaw or other agent runtime wired through `--agent-cmd` ## Privacy and portability notes This public package intentionally excludes: - Telegram bot tokens and session targets - `.openclaw` runtime state - SQLite task databases - launchd worker configuration - private OpenClaw runtime/session/database orchestration code - executable upstream reference snapshots - user-specific logs and caches - private source PDFs or historical task outputs Do not publish generated paper decks unless you have the right to redistribute their source content and figures.

path: examples/sample-paper.md
content: # Sample Paper

This is a tiny public sample used only for smoke testing the Beamer seven-phase pipeline runner.

## Model

Let $X_t$ denote a state variable and let $V(t,x)$ be a value function.

\[
  V_t + \mu x V_x + \frac{1}{2}\sigma^2 x^2 V_{xx} - rV = 0.
\]

## Result

The Beamer deck should explain the model, define symbols before first visible use, and keep equation coverage explicit.


path: package.json
content: {
  "name": "beamer-pipeline-public",
  "version": "0.3.1",
  "private": false,
  "description": "Public OpenClaw skill for running a seven-phase Chinese academic Beamer pipeline. Updated with latest symbol canonicalization and acceptance contract modules.",
  "type": "commonjs",
  "bin": {
    "beamer-pipeline-public": "scripts/run_beamer_public.js"
  },
  "scripts": {
    "help": "node scripts/run_beamer_public.js --help",
    "smoke": "node scripts/run_beamer_public.js --help && node scripts/run_beamer_public.js --input examples/sample-paper.md --out /tmp/beamer-pipeline-public-smoke --skip-assets --dry-run"
  },
  "engines": {
    "node": ">=18"
  },
  "license": "UNLICENSED"
}


path: references/UPSTREAM_DESIGN_NOTES.md
content: # Upstream Design Notes

This public skill is derived from a private local Beamer workflow, but the private runtime implementation is intentionally excluded from this package.

The public package keeps only the reusable, local-first Beamer contract and helper scripts:

- seven-phase Beamer generation shape
- prompt/workspace scaffolding
- local asset preparation for Markdown image references
- local validation helpers for slide/equation/acceptance contracts

The following private implementation layers are not included:

- Telegram or slash-command adapters
- private OpenClaw task queues or SQLite task databases
- private agent session recovery code
- local machine paths and launchd worker configuration
- private logs, caches, PDFs, generated decks, or user-specific state

If you need to connect this skill to an agent runtime, use the documented `--agent-cmd` hook and provide your own command in your own environment.


path: references/beamer-pipeline-code-explanation.md
content: # Beamer Pipeline Public Architecture Notes

This document summarizes the reusable public subset of the Beamer pipeline. It intentionally omits private Telegram queue, launchd worker, OpenClaw session, local user path, and historical task-cache details from the upstream implementation.

## Public package scope

The public skill is centered on a local-first Beamer conversion contract:

1. Prepare local source assets.
2. Generate or guide generation of `analysis.json`.
3. Generate or guide generation of `slides.json`.
4. Generate `main.tex`.
5. Compile `main.tex` to `main.pdf` when a LaTeX toolchain is available.
6. Validate structured final acceptance fields.

## Included public files

- `SKILL.md`: skill-facing usage, inputs, outputs, and privacy notes.
- `templates/beamer_contract.md`: reusable Beamer generation contract.
- `scripts/run_beamer_public.js`: minimal public CLI wrapper.
- `scripts/prepare_task_assets.js`: markdown image localization and `asset_manifest.json` writer.
- `scripts/beamer_acceptance_contract.js`: structured final acceptance-field validator.
- `scripts/deck_symbol_canonicalization.js`: symbol canonicalization helper.
- `scripts/slide_schema.js`: slide/equation block normalization helper.

## Public call chain

```text
node scripts/run_beamer_public.js --input <source.md> --out <output-dir>
  -> scripts/prepare_task_assets.js <source.md> <output-dir> --mode beamer
  -> templates/beamer_contract.md copied into <output-dir>/beamer_task_contract.md
  -> downstream OpenClaw/agent generation uses that contract
  -> final output directory contains analysis.json, slides.json, main.tex, main.pdf, README.md, asset_manifest.json, figures/
```

## Artifact contract

A successful Beamer package should place every generated artifact in one canonical output directory:

- `analysis.json`
- `slides.json`
- `main.tex`
- `main.pdf` when compilation succeeds
- `README.md`
- `asset_manifest.json`
- `figures/`

Generated artifacts should not be split across historical fallback directories.

## Validation contract

The final response or machine-readable final report should expose these structured fields:

- `compile_status`
- `readability_status`
- `tex_warnings`
- `layout_policy`
- `visible_prose_recovery_hint`
- `visible_prose_fidelity_final`
- `render_fidelity_safeguards`

These fields are checked by `scripts/beamer_acceptance_contract.js`.

## Explicit exclusions

The public package does not include the private upstream runtime layers:

- messaging/slash-command adapters
- task queue database
- progress notifier
- long-running worker launcher
- local agent session recovery
- local machine paths
- private logs, caches, PDFs, and generated decks

The upstream reference snapshots, if present during local development, are excluded from publication by `.skillignore` and require manual review before sharing.


path: scripts/beamer_acceptance_contract.js
content: const STRUCTURED_FINAL_FIELDS = [
  {
    key: "compile_status",
    description: "structured compile outcome with status, main_pdf_generated, command/logs, and blocker_or_warning_summary",
  },
  {
    key: "readability_status",
    description: "structured readability outcome with overfull_warning_count, severity, and why the deck is or is not reviewable",
  },
  {
    key: "tex_warnings",
    description: "structured raw TeX warning inventory, including tex_warnings.overfull_boxes[] entries from compile logs",
  },
  {
    key: "layout_policy",
    description: "structured layout policy outcome, including layout_policy.overfull_assessment severity and gate decision",
  },
  {
    key: "visible_prose_recovery_hint",
    description: "non-gating recovery hint that may be partial or sampled while the final visible prose audit is still pending",
  },
  {
    key: "visible_prose_fidelity_final",
    description: "structured full-deck audit that checks whether slides.json visible explanatory prose remains visibly present in the rendered deck",
  },
  {
    key: "render_fidelity_safeguards",
    description: "structured list/object describing the concrete safeguards used to prevent scaffold-only or prose-loss regressions",
  },
];

const PPT_STRUCTURED_FINAL_FIELDS = [
  {
    key: "render_status",
    description: "structured PPT renderer outcome with status, main_pptx_generated, command/logs, and blocker_or_warning_summary",
  },
  {
    key: "validation_status",
    description: "structured pptx_validation.json outcome with ok/fatal/warning counts and report path",
  },
  {
    key: "pptx_warnings",
    description: "structured PPT validator warning inventory, including issues[] or warning_count plus summary",
  },
  {
    key: "layout_policy",
    description: "structured layout policy outcome, including layout_policy.overfull_assessment severity and gate decision",
  },
  {
    key: "visible_prose_recovery_hint",
    description: "non-gating recovery hint that may be partial or sampled while the final visible prose audit is still pending",
  },
  {
    key: "visible_prose_fidelity_final",
    description: "structured full-deck audit that checks whether slides.json visible explanatory prose remains visibly present in the rendered PPT deck",
  },
  {
    key: "render_fidelity_safeguards",
    description: "structured list/object describing the concrete safeguards used to prevent scaffold-only, prose-loss, or renderer regressions",
  },
];

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function isNonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0;
}

function isNonEmptyStructuredValue(value) {
  if (isNonEmptyString(value)) return true;
  if (Array.isArray(value)) return value.length > 0;
  if (isPlainObject(value)) return Object.keys(value).length > 0;
  return false;
}

function firstNonEmptyString(...values) {
  return values.find((value) => isNonEmptyString(value)) || "";
}

function structuredCompileStatusValid(value) {
  if (isNonEmptyString(value)) return true;
  if (!isPlainObject(value)) return false;
  const status = String(value.status || "").trim().toLowerCase();
  const validStatuses = ["compiled", "compiled_with_warnings", "blocked", "failed", "partial", "pass", "success", "succeeded", "ok", "done", "warning"];
  if (!validStatuses.includes(status)) return false;
  if (typeof value.main_pdf_generated !== "boolean") return false;
  const commandOk = isNonEmptyString(value.command) || isNonEmptyString(value.compile_command);
  const explanationOk = isNonEmptyString(value.blocker_or_warning_summary)
    || isNonEmptyString(value.summary)
    || isNonEmptyString(value.warning_summary)
    || isNonEmptyString(value.notes);
  return commandOk && explanationOk;
}

function structuredReadabilityStatusValid(value) {
  if (isNonEmptyString(value)) return true;
  if (!isPlainObject(value)) return false;
  const severity = String(value.severity || value.status || "").trim().toLowerCase();
  const validSeverities = ["none", "ok", "pass", "reviewable", "warning", "minor", "moderate", "severe", "blocked", "fail"];
  if (!validSeverities.includes(severity)) return false;
  const explanationOk = isNonEmptyString(firstNonEmptyString(
    value.summary,
    value.reason,
    value.why,
    value.why_reviewable,
    value.why_not_blocked,
    value.layout_outcome,
    value.assessment,
    value.notes,
    value.justification,
    value.gate_effect
  ));
  return explanationOk;
}

function structuredVisibleProseFidelityValid(value) {
  if (!isPlainObject(value)) return false;
  const status = String(value.status || "").trim().toLowerCase();
  const validStatuses = ["pass", "warning", "fail"];
  if (!validStatuses.includes(status)) return false;
  if (!Array.isArray(value.checked_slide_ids)) return false;
  if (!Number.isFinite(Number(value.checked_slide_count))) return false;
  if (!Number.isFinite(Number(value.total_slide_count))) return false;
  if (!Number.isFinite(Number(value.coverage_ratio))) return false;
  if (!Array.isArray(value.uncovered_source_segments)) return false;
  if (!Array.isArray(value.omitted_by_design)) return false;
  const explanationOk = isNonEmptyString(value.summary)
    || isNonEmptyString(value.justification)
    || isNonEmptyString(value.notes)
    || (value.checked_slide_ids.length === Number(value.checked_slide_count)
      && Array.isArray(value.omitted_by_design));
  const evidenceOk = isNonEmptyStructuredValue(value.evidence)
    || isNonEmptyStructuredValue(value.examples)
    || value.checked_slide_ids.length === Number(value.checked_slide_count);
  return explanationOk && evidenceOk;
}

function structuredVisibleProseRecoveryHintValid(value) {
  if (!isPlainObject(value)) return false;
  const status = String(value.status || "").trim().toLowerCase();
  const validStatuses = ["partial", "blocked", "warning", "advisory", "advice", "hint", "non_gating_hint", "non_gating_recovery_hint", "non_gating_partial_audit", "non_gating_sampled_pass", "recovery_hint", "sampled_recovery_hint"];
  if (!validStatuses.includes(status)) return false;
  const explanationOk = isNonEmptyString(value.summary)
    || isNonEmptyString(value.justification)
    || isNonEmptyString(value.notes)
    || isNonEmptyString(value.hint)
    || isNonEmptyString(value.scope);
  const evidenceOk = Array.isArray(value.checked_slide_ids)
    || Array.isArray(value.sample_slide_ids)
    || Array.isArray(value.sampled_slide_ids)
    || Array.isArray(value.checks_performed)
    || isNonEmptyStructuredValue(value.evidence)
    || isNonEmptyStructuredValue(value.examples)
    || ["advisory", "advice", "hint", "non_gating_hint", "non_gating_recovery_hint", "non_gating_partial_audit", "non_gating_sampled_pass", "recovery_hint", "sampled_recovery_hint"].includes(status);
  const nonGating = value.non_gating === true
    || value.blocking === false
    || ["advisory", "advice", "hint", "non_gating_hint", "non_gating_recovery_hint", "non_gating_partial_audit", "non_gating_sampled_pass", "recovery_hint", "sampled_recovery_hint"].includes(status);
  return explanationOk && evidenceOk && nonGating;
}

function structuredTexWarningsValid(value) {
  if (!isPlainObject(value)) return false;
  const overfullBoxes = value.overfull_boxes;
  if (!Array.isArray(overfullBoxes)) return false;
  const entriesOk = overfullBoxes.every((item) => isPlainObject(item)
    && (isNonEmptyString(item.kind) || isNonEmptyString(item.box_type) || isNonEmptyString(item.type))
    && isNonEmptyString(firstNonEmptyString(item.raw, item.raw_message, item.message)));
  const explanationOk = isNonEmptyString(firstNonEmptyString(
    value.summary,
    value.notes,
    value.warning_summary
  ))
    || typeof value.overfull_warning_count === "number"
    || typeof value.overfull_count === "number";
  return entriesOk && explanationOk;
}

function structuredLayoutPolicyValid(value) {
  if (!isPlainObject(value)) return false;
  const assessment = isPlainObject(value.overfull_assessment)
    ? value.overfull_assessment
    : (isNonEmptyString(value.overfull_assessment)
      ? { severity: value.overfull_assessment, gate_decision: value.gate_decision || value.policy_decision || "pass", summary: value.summary || value.notes || value.policy_notes }
      : null);
  if (!isPlainObject(assessment)) return false;
  const severity = String(assessment.severity || "").trim().toLowerCase();
  const validSeverities = ["none", "minor", "moderate", "severe"];
  if (!validSeverities.includes(severity)) return false;
  const gateDecision = String(assessment.gate_decision || "").trim().toLowerCase();
  const validGateDecisions = ["pass", "repair", "fail"];
  if (!validGateDecisions.includes(gateDecision)) return false;
  return isNonEmptyString(firstNonEmptyString(
    assessment.summary,
    assessment.notes,
    assessment.evidence,
    assessment.threshold_used,
    assessment.rationale,
    assessment.reason,
    assessment.justification,
    value.summary,
    value.notes,
    value.policy_notes
  ));
}

function structuredRenderFidelitySafeguardsValid(value) {
  if (isNonEmptyString(value)) return true;
  if (Array.isArray(value)) return value.length > 0;
  if (!isPlainObject(value)) return false;
  return isNonEmptyStructuredValue(value.checks)
    || isNonEmptyStructuredValue(value.checks_used)
    || isNonEmptyStructuredValue(value.rules)
    || isNonEmptyStructuredValue(value.safeguards)
    || isNonEmptyStructuredValue(value.summary)
    || isNonEmptyStructuredValue(value.notes);
}

function structuredPptRenderStatusValid(value) {
  if (isNonEmptyString(value)) return true;
  if (!isPlainObject(value)) return false;
  const status = String(value.status || "").trim().toLowerCase();
  const validStatuses = ["rendered", "rendered_with_warnings", "blocked", "failed", "partial", "pass", "success", "succeeded", "ok", "done", "warning"];
  if (!validStatuses.includes(status)) return false;
  const generatedFlag = typeof value.main_pptx_generated === "boolean"
    || typeof value.main_pptx_exists === "boolean";
  if (!generatedFlag) return false;
  const commandOk = isNonEmptyString(value.command) || isNonEmptyString(value.render_command) || isNonEmptyString(value.renderer_command);
  const explanationOk = isNonEmptyString(value.blocker_or_warning_summary)
    || isNonEmptyString(value.summary)
    || isNonEmptyString(value.warning_summary)
    || isNonEmptyString(value.notes);
  return commandOk && explanationOk;
}

function structuredPptValidationStatusValid(value) {
  if (isNonEmptyString(value)) return true;
  if (!isPlainObject(value)) return false;
  const hasOkFlag = typeof value.ok === "boolean";
  const status = String(value.status || "").trim().toLowerCase();
  const validStatuses = ["pass", "ok", "success", "succeeded", "warning", "blocked", "failed", "fail"];
  if (!hasOkFlag && !validStatuses.includes(status)) return false;
  const countOk = Number.isFinite(Number(value.fatal_count ?? value.error_count ?? 0))
    && Number.isFinite(Number(value.warning_count ?? 0));
  const reportOk = isNonEmptyString(value.report_path)
    || isNonEmptyString(value.validation_report)
    || isNonEmptyString(value.pptx_validation_json)
    || isNonEmptyString(value.path);
  const explanationOk = isNonEmptyString(value.summary)
    || isNonEmptyString(value.notes)
    || isNonEmptyString(value.blocker_or_warning_summary);
  return countOk && reportOk && explanationOk;
}

function structuredPptWarningsValid(value) {
  if (!isPlainObject(value)) return false;
  const issues = Array.isArray(value.issues) ? value.issues : (Array.isArray(value.warnings) ? value.warnings : []);
  const entriesOk = issues.every((item) => {
    if (isPlainObject(item)) {
      return isNonEmptyString(item.message) || isNonEmptyString(item.code) || isNonEmptyString(item.summary);
    }
    return isNonEmptyString(item);
  });
  const hasCount = Number.isFinite(Number(value.warning_count ?? value.count ?? issues.length));
  const explanationOk = isNonEmptyString(value.summary)
    || isNonEmptyString(value.notes)
    || hasCount;
  return entriesOk && hasCount && explanationOk;
}

function validateFinalBeamerAcceptanceFields(content) {
  const errors = [];
  if (!isPlainObject(content)) return errors;

  const validators = {
    compile_status: structuredCompileStatusValid,
    readability_status: structuredReadabilityStatusValid,
    tex_warnings: structuredTexWarningsValid,
    layout_policy: structuredLayoutPolicyValid,
    visible_prose_recovery_hint: structuredVisibleProseRecoveryHintValid,
    visible_prose_fidelity_final: structuredVisibleProseFidelityValid,
    render_fidelity_safeguards: structuredRenderFidelitySafeguardsValid,
  };

  for (const field of STRUCTURED_FINAL_FIELDS) {
    if (!Object.prototype.hasOwnProperty.call(content, field.key)) {
      errors.push(`programmer structured deliverable field missing: ${field.key}`);
      continue;
    }
    if (!validators[field.key](content[field.key])) {
      errors.push(`programmer structured deliverable field invalid: ${field.key} must be ${field.description}`);
    }
  }
  return errors;
}

function validateFinalPptAcceptanceFields(content) {
  const errors = [];
  if (!isPlainObject(content)) return errors;

  const validators = {
    render_status: structuredPptRenderStatusValid,
    validation_status: structuredPptValidationStatusValid,
    pptx_warnings: structuredPptWarningsValid,
    layout_policy: structuredLayoutPolicyValid,
    visible_prose_recovery_hint: structuredVisibleProseRecoveryHintValid,
    visible_prose_fidelity_final: structuredVisibleProseFidelityValid,
    render_fidelity_safeguards: structuredRenderFidelitySafeguardsValid,
  };

  for (const field of PPT_STRUCTURED_FINAL_FIELDS) {
    if (!Object.prototype.hasOwnProperty.call(content, field.key)) {
      errors.push(`programmer structured deliverable field missing: ${field.key}`);
      continue;
    }
    if (!validators[field.key](content[field.key])) {
      errors.push(`programmer structured deliverable field invalid: ${field.key} must be ${field.description}`);
    }
  }
  return errors;
}

function buildPromptLinesForFinalBeamer() {
  return [
    "For final-phase Beamer tasks, add these additional structured acceptance fields to the same top-level JSON object: compile_status, readability_status, tex_warnings, layout_policy, visible_prose_recovery_hint, visible_prose_fidelity_final, render_fidelity_safeguards.",
    "compile_status must concretely state whether main.pdf was generated, which compile command was used, where logs live, and what blocker/warning summary remains.",
    "readability_status must concretely state the layout/readability outcome and summarize the overfull assessment instead of treating every overfull warning as a hard failure.",
    "tex_warnings.overfull_boxes must preserve the raw TeX warning facts, one entry per parsed overfull warning when available.",
    "layout_policy.overfull_assessment must classify overfull warnings into none/minor/moderate/severe and choose gate_decision=pass/repair/fail accordingly.",
    "visible_prose_recovery_hint is a non-gating recovery/debug hint and may stay partial or sampled.",
    "visible_prose_fidelity_final must be the full-deck gating audit with status pass|warning|fail, full checked slide IDs, checked_slide_count, total_slide_count, coverage_ratio, uncovered_source_segments, and omitted_by_design.",
    "render_fidelity_safeguards must concretely list the safeguards/checks used to prevent scaffold leakage, prose loss, or render-only skeleton output.",
    "Do not rely on free prose alone for those final Beamer checks; fill the structured fields explicitly.",
  ];
}

function buildPromptLinesForFinalPpt() {
  return [
    "For final-phase PPT tasks, add these additional structured acceptance fields to the same top-level JSON object: render_status, validation_status, pptx_warnings, layout_policy, visible_prose_recovery_hint, visible_prose_fidelity_final, render_fidelity_safeguards.",
    "render_status must concretely state whether main.pptx was generated, which renderer command was used, where logs live if any, and what blocker/warning summary remains.",
    "validation_status must summarize pptx_validation.json with ok/fatal_count/warning_count and the concrete report path.",
    "pptx_warnings must preserve validator warning facts from pptx_validation.json issues/warnings; use warning_count=0 plus a summary when there are no warnings.",
    "layout_policy.overfull_assessment must classify PPT layout/render warnings into none/minor/moderate/severe and choose gate_decision=pass/repair/fail accordingly.",
    "visible_prose_recovery_hint is a non-gating recovery/debug hint and may stay partial or sampled.",
    "visible_prose_fidelity_final must be the full-deck gating audit with status pass|warning|fail, full checked slide IDs, checked_slide_count, total_slide_count, coverage_ratio, uncovered_source_segments, and omitted_by_design.",
    "render_fidelity_safeguards must concretely list the validator/renderer/scaffold-leakage/prose-retention checks used to prevent a skeleton or prose-loss PPT output.",
    "Do not rely on free prose alone for those final PPT checks; fill the structured fields explicitly.",
  ];
}

function buildStructuredValidationTokens(content) {
  if (!isPlainObject(content)) return [];
  const tokens = [];

  const compileStatus = content.compile_status;
  if (isNonEmptyStructuredValue(compileStatus)) {
    tokens.push("compile_status", "编译状态", "compile", "latexmk", "xelatex", "main.pdf", "log", JSON.stringify(compileStatus));
  }

  const readabilityStatus = content.readability_status;
  if (isNonEmptyStructuredValue(readabilityStatus)) {
    tokens.push("readability_status", "可读性", "版式", "overfull", "warning", "severity", JSON.stringify(readabilityStatus));
  }

  const texWarnings = content.tex_warnings;
  if (isNonEmptyStructuredValue(texWarnings)) {
    tokens.push("tex_warnings", "overfull", "warning", "tex warning", "原始 TeX 告警", JSON.stringify(texWarnings));
  }

  const layoutPolicy = content.layout_policy;
  if (isNonEmptyStructuredValue(layoutPolicy)) {
    tokens.push("layout_policy", "overfull_assessment", "minor", "moderate", "severe", "gate decision", JSON.stringify(layoutPolicy));
  }

  const visibleProseRecoveryHint = content.visible_prose_recovery_hint;
  if (isNonEmptyStructuredValue(visibleProseRecoveryHint)) {
    tokens.push("visible_prose_recovery_hint", "visible prose", "恢复提示", "non-gating", JSON.stringify(visibleProseRecoveryHint));
  }

  const visibleProseFidelity = content.visible_prose_fidelity_final;
  if (isNonEmptyStructuredValue(visibleProseFidelity)) {
    tokens.push("visible_prose_fidelity_final", "visible prose", "可见正文", "全量核验", "lower-bound visible-content contract", "slides.json", "main.tex", JSON.stringify(visibleProseFidelity));
  }

  const renderFidelitySafeguards = content.render_fidelity_safeguards;
  if (isNonEmptyStructuredValue(renderFidelitySafeguards)) {
    tokens.push("render_fidelity_safeguards", "render fidelity", "safeguards", "safeguard", "防护", "自检", JSON.stringify(renderFidelitySafeguards));
  }

  const renderStatus = content.render_status;
  if (isNonEmptyStructuredValue(renderStatus)) {
    tokens.push("render_status", "PPT 渲染状态", "renderer", "main.pptx", "render command", JSON.stringify(renderStatus));
  }

  const validationStatus = content.validation_status;
  if (isNonEmptyStructuredValue(validationStatus)) {
    tokens.push("validation_status", "pptx_validation.json", "validator", "fatal_count", "warning_count", JSON.stringify(validationStatus));
  }

  const pptxWarnings = content.pptx_warnings;
  if (isNonEmptyStructuredValue(pptxWarnings)) {
    tokens.push("pptx_warnings", "PPT validator warnings", "layout warning", "issues", JSON.stringify(pptxWarnings));
  }

  return tokens;
}

function extractArtifactPaths(content) {
  if (!isPlainObject(content?.artifact_paths)) return {};
  const result = {};
  for (const [key, value] of Object.entries(content.artifact_paths)) {
    if (isNonEmptyString(value)) result[key] = value.trim();
  }
  return result;
}

function collectCheckpointArtifactPaths(checkpoint) {
  if (!isPlainObject(checkpoint)) return {};
  const merged = {};
  const candidates = [];
  if (isPlainObject(checkpoint.final_programmer?.content)) {
    candidates.push(checkpoint.final_programmer.content);
  }
  const rounds = Array.isArray(checkpoint.rounds) ? [...checkpoint.rounds].reverse() : [];
  for (const round of rounds) {
    if (isPlainObject(round?.programmer?.content)) {
      candidates.push(round.programmer.content);
    }
  }
  for (const content of candidates) {
    Object.assign(merged, extractArtifactPaths(content));
  }
  return merged;
}

module.exports = {
  STRUCTURED_FINAL_FIELDS,
  PPT_STRUCTURED_FINAL_FIELDS,
  validateFinalBeamerAcceptanceFields,
  validateFinalPptAcceptanceFields,
  buildPromptLinesForFinalBeamer,
  buildPromptLinesForFinalPpt,
  buildStructuredValidationTokens,
  collectCheckpointArtifactPaths,
};


path: scripts/beamer_phase_blueprint.js
content: "use strict";

const DECK_PIPELINE_PHASE_BLUEPRINTS = Object.freeze([
  {
    name: "analysis",
    title: "分析",
    goal: "建立 analysis.json，并补齐 paragraph_ledger / figure / table / equation / notation / formal statement 等源侧盘点基线。",
    requiredArtifacts: ["analysis.json"],
    reviewerPhase: false,
    finalPhase: false,
  },
  {
    name: "slides_outline_skeleton",
    title: "slides 大纲 / skeleton",
    goal: "生成 slides.json，明确路线图、结论预告、正文/附录拆分与逐页 skeleton；equation_coverage / notation_coverage 在本阶段可先保留 planned 占位。",
    requiredArtifacts: ["analysis.json", "slides.json"],
    reviewerPhase: true,
    finalPhase: false,
  },
  {
    name: "equation_coverage",
    title: "公式覆盖",
    goal: "把 equation_coverage 真正落到 slide/frame 映射与解释上，不再保留 planned/blocked/missing/partial 占位。",
    requiredArtifacts: ["analysis.json", "slides.json"],
    reviewerPhase: true,
    finalPhase: false,
  },
  {
    name: "notation_consistency",
    title: "记号 / 一致性",
    goal: "补齐 notation_coverage，并修正符号首现定义、先定义后使用与跨页一致性问题。",
    requiredArtifacts: ["analysis.json", "slides.json"],
    reviewerPhase: true,
    finalPhase: false,
  },
  {
    name: "compile_and_structural_repair",
    title: "编译与结构修复",
    goal: "生成主工件并完成编译、结构、版式与渲染保真修补。",
    requiredArtifacts: ["analysis.json", "slides.json", "main.tex", "main.pdf"],
    reviewerPhase: true,
    finalPhase: false,
  },
  {
    name: "review_and_auto_rework",
    title: "reviewer 审核与自动返工",
    goal: "把 reviewer 打回转成 repair tickets，自动返工直到 reviewer 放行。",
    requiredArtifacts: ["analysis.json", "slides.json", "main.tex", "main.pdf"],
    reviewerPhase: true,
    finalPhase: false,
  },
  {
    name: "final_acceptance_delivery",
    title: "终验与交付",
    goal: "补齐最终结构化验收字段，跑 tester / 终验，并交付 ready_for_review=true 的最终结果。",
    requiredArtifacts: ["analysis.json", "slides.json", "main.tex", "main.pdf", "README.md", "asset_manifest.json", "figures"],
    reviewerPhase: false,
    finalPhase: true,
  },
]);

const DECK_PIPELINE_TOTAL_PHASES = DECK_PIPELINE_PHASE_BLUEPRINTS.length;
const DECK_PIPELINE_PHASE_INDEX = Object.freeze(DECK_PIPELINE_PHASE_BLUEPRINTS.reduce((acc, spec, index) => {
  acc[spec.name] = index + 1;
  return acc;
}, {}));

function getBeamerPipelinePhase(round) {
  const normalizedRound = Math.min(Math.max(Number(round || 1), 1), DECK_PIPELINE_TOTAL_PHASES);
  const spec = DECK_PIPELINE_PHASE_BLUEPRINTS[normalizedRound - 1];
  return {
    ...spec,
    index: normalizedRound,
    total: DECK_PIPELINE_TOTAL_PHASES,
    mode: "beamer",
    finalArtifact: "main.tex",
  };
}

function summarizePhase(phase) {
  if (!phase) return "";
  return `第 ${phase.index}/${phase.total} 阶段：${phase.title}`;
}

function phaseAllowsPlannedCoverageField(phase, fieldName) {
  const phaseIndex = Number(phase?.index || 0) || 0;
  if (!phase || phase.finalPhase === true || phaseIndex <= 0) return false;
  const lastPlanningPhaseIndex = fieldName === "equation_coverage"
    ? DECK_PIPELINE_PHASE_INDEX.slides_outline_skeleton
    : (fieldName === "notation_coverage"
      ? DECK_PIPELINE_PHASE_INDEX.equation_coverage
      : DECK_PIPELINE_PHASE_INDEX.analysis);
  return phaseIndex <= lastPlanningPhaseIndex;
}

function requiredArtifactNamesForPhase(phase) {
  return Array.isArray(phase?.requiredArtifacts) ? [...phase.requiredArtifacts] : [];
}

module.exports = {
  DECK_PIPELINE_PHASE_BLUEPRINTS,
  DECK_PIPELINE_TOTAL_PHASES,
  DECK_PIPELINE_PHASE_INDEX,
  getBeamerPipelinePhase,
  phaseAllowsPlannedCoverageField,
  requiredArtifactNamesForPhase,
  summarizePhase,
};


path: scripts/coverage_status.js
content: "use strict";

function normalizeCoverageStatus(value) {
  const raw = String(value || "").trim().toLowerCase();
  if (!raw) return "";
  if (
    ["not_started", "not-started", "todo", "pending", "unmapped", "unassigned", "planned_ready", "phase_1_baseline_captured", "phase_1_placeholder", "present_in_analysis_json"].includes(raw)
    || /^pending_phase_\d+_mapping$/.test(raw)
    || /^planned_phase_\d+$/.test(raw)
    || /^phase_?\d+_planned_ready$/.test(raw)
    || /^phase_\d+_placeholder$/.test(raw)
    || /^phase_\d+_baseline_captured$/.test(raw)
  ) {
    return "planned";
  }
  if (
    [
      "inventory",
      "inventied",
      "inventoried",
      "catalogued",
      "cataloged",
      "baseline_inventory",
      "inventory_only",
      "inventory_ready",
      "source_inventory",
      "inventory_complete",
      "source_inventory_complete",
      "inventory_complete_pending_slides",
      "inventory_ready_pending_slide_mapping",
      "inventory_ready_pending_slides",
      "inventory_ready_pending_mapping",
    ].includes(raw)
    || /^phase_?\d+_inventory$/.test(raw)
    || /^phase_?\d+_inventoried$/.test(raw)
    || /^phase_?\d+_inventied$/.test(raw)
    || /^phase_?\d+_inventory_complete$/.test(raw)
    || /^inventory_complete_pending_(?:slides|mapping|placement)$/.test(raw)
    || /^inventory_ready_pending_(?:slides|mapping|slide_mapping|placement)$/.test(raw)
  ) {
    return "analysis_only";
  }
  if (
    ["mapped", "mapping_complete", "mapping-complete", "fully_mapped", "fully-mapped", "structured_ready"].includes(raw)
    || /^phase_?\d+_structured_ready$/.test(raw)
  ) {
    return "covered";
  }
  return raw;
}

module.exports = {
  normalizeCoverageStatus,
};


path: scripts/deck_equation_coverage_repair.js
content: #!/usr/bin/env node
/**
 * deck_equation_coverage_repair.js
 *
 * Deterministic auto-repair for equation_coverage defects in Beamer deck pipelines.
 * Consumes scanner output and applies targeted patches to main.tex frames
 * without requiring full programmer re-generation.
 *
 * Repair strategies (in order of preference):
 *   A. Frame has inline `$\displaystyle \begin{aligned}...\end{aligned}\tag{X}$`
 *      → Annotate with a hidden `\[...\]` wrapper so the validator regex matches.
 *      (The actual fix is in the orchestrator regex — see below.)
 *
 *   B. Frame has equation content but missing `\tag{X}` marker
 *      → Insert the appropriate `\tag{}` from analysis.json equation ledger.
 *
 *   C. Frame is missing the equation entirely, but source paragraph is known
 *      → Extract equation LaTeX from analysis.json paragraph_ledger and insert.
 *
 *   D. equation_coverage format mismatch (eq. numbers vs tagged keys)
 *      → Auto-correct the equation_numbers field in coverage (when allowed).
 *
 * Usage:
 *   node scripts/deck_equation_coverage_repair.js <task-dir> [--dry-run] [--scanner-output <json-path>]
 *
 * With --dry-run: reports what would be changed without modifying files.
 * Without --dry-run: applies patches and writes modified main.tex.
 */

"use strict";

const fs = require("fs");
const path = require("path");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function isPlainObject(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function readJsonFile(filePath) {
  try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; }
}

function readBalancedTexGroup(source, startIndex, open, close) {
  let depth = 0;
  let i = startIndex;
  while (i < source.length) {
    const ch = source[i];
    if (ch === open) { depth++; }
    else if (ch === close) { depth--; if (depth === 0) return { value: source.slice(startIndex + 1, i), endIndex: i + 1 }; }
    i++;
  }
  return null;
}

// ---------------------------------------------------------------------------
// Frame parsing (abridged — full version in scanner)
// ---------------------------------------------------------------------------

function extractFrameLabelFromOptions(optionText) {
  const match = String(optionText || "").match(/(?:^|,)\s*label\s*=\s*([^,\]]+)/i);
  return match ? match[1].trim() : "";
}

function parseFramesWithPositions(tex) {
  const frames = [];
  const beginMarker = "\\begin{frame}";
  const endMarker = "\\end{frame}";
  let cursor = 0;

  while (cursor < tex.length) {
    const beginIndex = tex.indexOf(beginMarker, cursor);
    if (beginIndex === -1) break;
    let headerIndex = beginIndex + beginMarker.length;
    while (/\s/.test(tex[headerIndex] || "")) headerIndex++;

    if (tex[headerIndex] === "<") {
      const grp = readBalancedTexGroup(tex, headerIndex, "<", ">");
      if (grp) headerIndex = grp.endIndex;
      while (/\s/.test(tex[headerIndex] || "")) headerIndex++;
    }
    let optionText = "";
    if (tex[headerIndex] === "[") {
      const grp = readBalancedTexGroup(tex, headerIndex, "[", "]");
      if (grp) { optionText = grp.value; headerIndex = grp.endIndex; }
      while (/\s/.test(tex[headerIndex] || "")) headerIndex++;
    }
    if (tex[headerIndex] === "{") {
      const grp = readBalancedTexGroup(tex, headerIndex, "{", "}");
      if (grp) headerIndex = grp.endIndex;
    }

    const endIndex = tex.indexOf(endMarker, headerIndex);
    if (endIndex === -1) break;

    const body = tex.slice(headerIndex, endIndex);
    const fullFrame = tex.slice(beginIndex, endIndex + endMarker.length);
    const label = extractFrameLabelFromOptions(optionText);
    const frameEndInTex = endIndex + endMarker.length;

    // Check if body uses $\displaystyle \begin{aligned}...\end{aligned}\tag{X}$
    const alignedMatch = body.match(/\$\\displaystyle\s*(\\begin\{aligned\*?\}[\s\S]*?\\end\{aligned\*?\})\s*\\tag\{([^}]+)\}\s*\$/);
    const hasInlineAligned = alignedMatch !== null;

    // Extract all \tag{...} values
    const tags = [];
    for (const m of body.matchAll(/\\tag\{([^}]+)\}/g)) {
      tags.push(m[1].trim());
    }

    frames.push({
      index: frames.length,
      label,
      frameStartInTex: beginIndex,
      frameEndInTex,
      bodyStartInTex: headerIndex,
      bodyEndInTex: endIndex,
      fullFrame,
      body,
      hasInlineAligned,
      alignedBody: alignedMatch ? alignedMatch[1] : null,
      alignedTag: alignedMatch ? alignedMatch[2] : null,
      tags,
    });
    cursor = endIndex + endMarker.length;
  }
  return frames;
}

// ---------------------------------------------------------------------------
// Equation ledger extraction
// ---------------------------------------------------------------------------

function buildEquationLedger(analysis) {
  const ledger = new Map();
  const paragraphs = Array.isArray(analysis?.paragraph_ledger) ? analysis.paragraph_ledger : [];

  for (const para of paragraphs) {
    if (!isPlainObject(para)) continue;
    const tags = Array.isArray(para.equation_tags) ? para.equation_tags : [];
    const excerpt = String(para.source_excerpt || "");

    // Extract numbered equation blocks from source_excerpt
    for (const tag of tags) {
      const tagStr = String(tag).trim();
      // Map paragraph's tagged equation numbers to their LaTeX
      if (!ledger.has(tagStr)) {
        // Try to extract the equation from the excerpt
        const eqMatch = excerpt.match(new RegExp(
          `(?:\\\\begin\\{(?:equation|align|aligned)\\*?\\}[\\s\\S]*?\\\\end\\{(?:equation|align|aligned)\\*?\\}|` +
          `\\$\\$[\\s\\S]*?\\$\\$|` +
          `\\\\\\[[\\s\\S]*?\\\\\\])` +
          `[\\s\\S]*?\\\\tag\\{${tagStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}`
        ));
        if (eqMatch) {
          ledger.set(tagStr, { tag: tagStr, latex: eqMatch[0], sourceParagraph: para.paragraph_id });
        }
      }
    }
  }

  return ledger;
}

// ---------------------------------------------------------------------------
// Repair actions
// ---------------------------------------------------------------------------

/**
 * Strategy A: For frames using $\displaystyle \begin{aligned}...\end{aligned}\tag{X}$,
 * the issue is that the validator's regex doesn't match \begin{aligned}.
 * We have two options:
 *   1. Patch the validator regex in orchestrator (add "aligned" pattern)
 *   2. Convert the equation in main.tex
 *
 * Since modifying inside adjustbox is risky, we recommend option 1.
 * This function generates the recommended orchestrator patch.
 */
function recommendAlignedRegexFix() {
  return {
    type: "orchestrator_regex_fix",
    file: "scripts/dev_pipeline_orchestrator.js",
    description: "Add \\begin{aligned} to the display equation regex so inline display math with aligned is counted",
    oldPattern: "\\\\begin\\{align\\*?\\}",
    newPattern: "\\\\begin\\{align(?:ed)?\\*?\\}",
    searchLine: "const displayEquationCount = (body.match(/\\\\eqs?\\{|\\\\begin\\{equation\\*?\\}|\\\\begin\\{align\\*?\\}|\\\\begin\\{gather\\*?\\}|\\\\begin\\{multline\\*?\\}|\\\\begin\\{flalign\\*?\\}|\\\\\\[/g) || []).length;",
  };
}

/**
 * Strategy B: Add missing \tag{} markers to frames that have aligned content.
 * This handles cases where the equation content exists but the tag number
 * expected by equation_coverage is missing or mismatched.
 */
function repairMissingTags(mainTex, frames, coverageEntries, equationLedger) {
  const patches = [];

  // Build map: slide_id → expected equation numbers
  const expectedBySlide = new Map();
  for (const entry of coverageEntries) {
    if (!isPlainObject(entry)) continue;
    const slideIds = Array.isArray(entry.slide_ids) ? entry.slide_ids : [entry.slide_id].filter(Boolean);
    const numbers = Array.isArray(entry.equation_numbers) ? entry.equation_numbers : [entry.equation_numbers].filter(Boolean);
    for (const sid of slideIds) {
      const sidStr = String(sid).trim();
      if (!expectedBySlide.has(sidStr)) expectedBySlide.set(sidStr, []);
      expectedBySlide.get(sidStr).push(...numbers);
    }
  }

  for (const frame of frames) {
    if (!frame.label) continue;
    const expected = expectedBySlide.get(frame.label.toLowerCase());
    if (!expected || expected.length === 0) continue;

    // Normalize: extract "2.1" from "Eq. (2.1)", etc.
    const expectedNums = new Set();
    for (const e of expected) {
      const str = String(e);
      const m = str.match(/(\d+\.\d+|[A-Z]\.\d+)/);
      if (m) expectedNums.add(m[1]);
      else if (/^\d+$/.test(str)) expectedNums.add(str);
    }

    const frameNums = new Set(frame.tags.map((t) => {
      const m = t.match(/(\d+\.\d+|[A-Z]\.\d+)/);
      return m ? m[1] : t;
    }));

    const missing = [...expectedNums].filter((n) => !frameNums.has(n));

    if (missing.length > 0 && frame.hasInlineAligned) {
      // The frame has inline aligned equations — add missing tags
      // We append \tag{missing_num} after the aligned block
      for (const num of missing) {
        patches.push({
          type: "add_missing_tag",
          frameLabel: frame.label,
          frameIndex: frame.index,
          equationNumber: num,
          description: `Frame ${frame.label} has inline aligned equation but missing \\tag{${num}}`,
          action: "add_tag_to_aligned_block",
        });
      }
    }
  }

  return patches;
}

/**
 * Strategy C: Insert missing equation block from analysis.json ledger into main.tex.
 */
function repairMissingEquations(mainTex, frames, coverageEntries, equationLedger) {
  const patches = [];
  const texSource = mainTex;

  // Build map as above
  const expectedBySlide = new Map();
  for (const entry of coverageEntries) {
    if (!isPlainObject(entry)) continue;
    const slideIds = Array.isArray(entry.slide_ids) ? entry.slide_ids : [entry.slide_id].filter(Boolean);
    for (const sid of slideIds) {
      const sidStr = String(sid).trim();
      if (!expectedBySlide.has(sidStr)) expectedBySlide.set(sidStr, []);
      expectedBySlide.get(sidStr).push(entry);
    }
  }

  for (const frame of frames) {
    if (!frame.label) continue;
    const entries = expectedBySlide.get(frame.label.toLowerCase());
    if (!entries || entries.length === 0) continue;

    for (const entry of entries) {
      const numbers = Array.isArray(entry.equation_numbers) ? entry.equation_numbers : [entry.equation_numbers].filter(Boolean);
      const frameNumSet = new Set(frame.tags);

      const allPresent = numbers.every((n) => {
        const str = String(n);
        const m = str.match(/(\d+\.\d+|[A-Z]\.\d+)/);
        const key = m ? m[1] : str;
        return frame.tags.some((t) => t.includes(key));
      });

      if (!allPresent && !frame.hasInlineAligned) {
        // Frame doesn't have any aligned block with tags — need to insert equation
        const sourceParagraphIds = Array.isArray(entry.source_paragraph_ids) ? entry.source_paragraph_ids : [];
        patches.push({
          type: "insert_equation_from_ledger",
          frameLabel: frame.label,
          frameIndex: frame.index,
          sourceLabel: entry.source_label || entry.label,
          equationNumbers: numbers,
          sourceParagraphIds,
          description: `Frame ${frame.label} missing entire equation block for ${numbers.join(", ")}`,
          action: "insert_equation_at_frame_end",
        });
      }
    }
  }

  return patches;
}

// ---------------------------------------------------------------------------
// Main repair function
// ---------------------------------------------------------------------------

function repairEquationCoverage(taskDir, options = {}) {
  const dryRun = options.dryRun || false;
  const analysisPath = path.join(taskDir, "analysis.json");
  const slidesPath = path.join(taskDir, "slides.json");
  const mainTexPath = path.join(taskDir, "main.tex");

  const analysis = readJsonFile(analysisPath);
  const slidesDoc = readJsonFile(slidesPath);

  if (!fs.existsSync(mainTexPath)) {
    return { error: "main.tex not found", taskDir };
  }

  const mainTex = fs.readFileSync(mainTexPath, "utf8");
  const frames = parseFramesWithPositions(mainTex);
  const equationLedger = buildEquationLedger(analysis);

  // Collect equation_coverage
  const coverage = Array.isArray(slidesDoc?.equation_coverage)
    ? slidesDoc.equation_coverage
    : (Array.isArray(analysis?.equation_coverage) ? analysis.equation_coverage : []);

  const report = {
    task_dir: taskDir,
    dry_run: dryRun,
    summary: {
      total_frames: frames.length,
      frames_with_labels: frames.filter((f) => f.label).length,
      frames_with_inline_aligned: frames.filter((f) => f.hasInlineAligned).length,
      total_coverage_entries: coverage.length,
      equation_ledger_size: equationLedger.size,
    },
    repairs: [],
    orchestrator_patches: [],
    applied: [],
  };

  // Collect all repair actions
  const tagPatches = repairMissingTags(mainTex, frames, coverage, equationLedger);
  const eqPatches = repairMissingEquations(mainTex, frames, coverage, equationLedger);
  const alignedFix = recommendAlignedRegexFix();

  report.repairs.push(...tagPatches, ...eqPatches);
  report.orchestrator_patches.push(alignedFix);

  // Apply repairs to main.tex if not dry-run
  if (!dryRun && (tagPatches.length > 0 || eqPatches.length > 0)) {
    let modifiedTex = mainTex;

    // Apply tag insertions
    for (const patch of tagPatches) {
      if (patch.action === "add_tag_to_aligned_block") {
        const frame = frames[patch.frameIndex];
        if (!frame || !frame.hasInlineAligned) continue;

        // Find the aligned block and insert \tag{X} after it
        // Pattern: $\displaystyle \begin{aligned}...\end{aligned}$
        // We want: $\displaystyle \begin{aligned}...\end{aligned}\tag{N}$
        const alignedRegex = /(\$\\displaystyle\s*\\begin\{aligned\*?\}[\s\S]*?\\end\{aligned\*?\})(\s*\$)/g;
        const body = frame.body;
        alignedRegex.lastIndex = 0;

        // Find the last aligned block in the body
        const matches = [...body.matchAll(alignedRegex)];
        if (matches.length > 0) {
          const lastMatch = matches[matches.length - 1];
          const oldBlock = lastMatch[0];
          const newBlock = oldBlock.replace(/(\s*\$)$/, `\\tag{${patch.equationNumber}}$1`);

          // Replace in full frame
          if (frame.fullFrame.includes(oldBlock)) {
            const newFrame = frame.fullFrame.replace(oldBlock, newBlock);
            modifiedTex = modifiedTex.replace(frame.fullFrame, newFrame);

            report.applied.push({
              type: "add_tag",
              frame: frame.label,
              equationNumber: patch.equationNumber,
              success: true,
            });
          }
        }
      }
    }

    // Apply equation insertions (for frames entirely missing equations)
    for (const patch of eqPatches) {
      if (patch.action === "insert_equation_at_frame_end") {
        const frame = frames[patch.frameIndex];
        if (!frame) continue;

        // Try to get equation from ledger
        const eqLatex = equationLedger.get(patch.equationNumbers[0]);
        if (eqLatex) {
          // Insert just before \end{frame}
          const insertBefore = "\\end{frame}";
          const insertion = `\n\\vspace{0.4em}\n\\uncover<+->{\\begin{adjustbox}{max width=0.96\\linewidth,max totalheight=0.43\\textheight}\n${eqLatex.latex}\n\\end{adjustbox}}\n`;

          const frameInTex = frame.fullFrame;
          const newFrame = frameInTex.replace(insertBefore, insertion + insertBefore);
          modifiedTex = modifiedTex.replace(frameInTex, newFrame);

          report.applied.push({
            type: "insert_equation",
            frame: frame.label,
            equation: patch.equationNumbers[0],
            fromLedger: true,
            success: true,
          });
        } else {
          report.applied.push({
            type: "insert_equation",
            frame: frame.label,
            equation: patch.equationNumbers.join(", "),
            fromLedger: false,
            success: false,
            reason: "equation not found in analysis.json ledger",
          });
        }
      }
    }

    // Write back
    const backupPath = mainTexPath + ".bak.eq-repair-" + Date.now();
    fs.copyFileSync(mainTexPath, backupPath);
    fs.writeFileSync(mainTexPath, modifiedTex, "utf8");

    report.summary.backup_path = backupPath;
    report.summary.main_tex_modified = true;
  }

  report.summary.total_repairs = report.repairs.length;
  report.summary.total_applied = report.applied.filter((a) => a.success).length;

  return report;
}

// ---------------------------------------------------------------------------
// Orchestrator patch helper
// Generates the exact regex replacement for parseMainTexFrames
// ---------------------------------------------------------------------------

function generateOrchestratorPatch() {
  return {
    description: "Fix display equation regex in parseMainTexFrames to include \\begin{aligned}",
    file: "scripts/dev_pipeline_orchestrator.js",
    searchFor: "|\\\\begin\\{align\\*?\\}|\\\\begin\\{gather\\*?\\}|\\\\begin\\{multline\\*?\\}|\\\\begin\\{flalign\\*?\\}|",
    replaceWith: "|\\\\begin\\{align(?:ed)?\\*?\\}|\\\\begin\\{gather\\*?\\}|\\\\begin\\{multline\\*?\\}|\\\\begin\\{flalign\\*?\\}|",
    note: "This makes \\begin{aligned} count as a visible equation block alongside \\begin{align}",
  };
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

if (require.main === module) {
  const args = process.argv.slice(2);
  const dryRun = args.includes("--dry-run");
  const taskDir = args.find((a) => !a.startsWith("--"));

  if (!taskDir) {
    console.error("Usage: node deck_equation_coverage_repair.js <task-dir> [--dry-run]");
    process.exit(1);
  }

  const result = repairEquationCoverage(taskDir, { dryRun });
  console.log(JSON.stringify(result, null, 2));
}

module.exports = {
  repairEquationCoverage,
  parseFramesWithPositions,
  buildEquationLedger,
  recommendAlignedRegexFix,
  generateOrchestratorPatch,
};


path: scripts/deck_equation_coverage_scanner.js
content: #!/usr/bin/env node
/**
 * deck_equation_coverage_scanner.js
 *
 * Cross-file diagnostic for equation coverage in Beamer deck pipelines.
 * Maps equation_coverage entries from slides.json / analysis.json against
 * actual visible content in main.tex Beamer frames.
 *
 * Usage:
 *   node scripts/deck_equation_coverage_scanner.js <task-dir>
 *
 * Output: JSON matrix of equation → slide → frame diagnostics.
 */

"use strict";

const fs = require("fs");
const path = require("path");

// ---------------------------------------------------------------------------
// Helpers (replicated from dev_pipeline_orchestrator.js to keep scanner standalone)
// ---------------------------------------------------------------------------

function isPlainObject(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function isNonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0;
}

function uniqueStrings(values) {
  return Array.from(new Set((Array.isArray(values) ? values : []).map(String).filter(Boolean)));
}

function readJsonFile(filePath) {
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf8"));
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// LaTeX token helpers
// ---------------------------------------------------------------------------

function readBalancedTexGroup(source, startIndex, open, close) {
  let depth = 0;
  let i = startIndex;
  while (i < source.length) {
    const ch = source[i];
    if (ch === open) { depth++; }
    else if (ch === close) {
      depth--;
      if (depth === 0) {
        return { value: source.slice(startIndex + 1, i), endIndex: i + 1 };
      }
    }
    i++;
  }
  return null;
}

// ---------------------------------------------------------------------------
// Equation number helpers
// ---------------------------------------------------------------------------

function extractEquationNumbersFromText(text) {
  const numbers = [];
  const source = String(text || "");
  // Match patterns: 2.1, A.1, B.2, (2.1), Eq. (2.1), etc.
  for (const match of source.matchAll(/(?:Eqs?\.?\s*)?\(?(\d+)\.(\d+)\)?/g)) {
    numbers.push(`${match[1]}.${match[2]}`);
  }
  // Also match appendix: A.1, B.2
  for (const match of source.matchAll(/\b([A-Z])\.(\d+)\b/g)) {
    // Skip if already captured as num.num
    numbers.push(`${match[1]}.${match[2]}`);
  }
  // Single numbers: 15, 16, etc.
  for (const match of source.matchAll(/(?:^|[^\d.])(\d{1,3})(?:[^\d.]|$)/g)) {
    const num = match[1];
    if (!numbers.includes(num) && !source.includes(`Eq. (${num})`) && !source.includes(`(${num}.`)) {
      // Only add standalone numbers if they appear as tagged equations
    }
  }
  return Array.from(new Set(numbers));
}

function equationKeysFromValues(numbers) {
  return (Array.isArray(numbers) ? numbers : []).map(String).filter(Boolean);
}

// ---------------------------------------------------------------------------
// Frame parsing (replicating parseMainTexFrames from orchestrator)
// ---------------------------------------------------------------------------

const DISPLAY_EQ_RE = /\\eqs?\{|\\begin\{equation\*?\}|\\begin\{align(?:ed)?\*?\}|\\begin\{gather\*?\}|\\begin\{multline\*?\}|\\begin\{flalign\*?\}|\\\[/g;
const TAG_RE = /\\tag\{([^}]+)\}/g;
const BEGIN_FRAME_RE = /\\begin\{frame\}/;
const END_FRAME_RE = /\\end\{frame\}/;

function normalizeFrameLabel(value) {
  return String(value || "").trim().toLowerCase();
}

function extractFrameLabelFromOptions(optionText) {
  const raw = String(optionText || "").trim();
  if (!raw) return "";
  const match = raw.match(/(?:^|,)\s*label\s*=\s*([^,\]]+)/i);
  return match ? match[1].trim() : "";
}

function frameTaggedEquationKeys(body) {
  const keys = [];
  const source = String(body || "");
  TAG_RE.lastIndex = 0;
  for (const match of source.matchAll(TAG_RE)) {
    const tagContent = String(match[1] || "").trim();
    const extracted = extractEquationNumbersFromText(tagContent);
    if (extracted.length > 0) {
      keys.push(...extracted);
    }
    if (/^\d+$/.test(tagContent)) {
      keys.push(tagContent);
    }
    const appendixMatch = tagContent.match(/^A0*([1-9]\d*)$/i);
    if (appendixMatch) {
      keys.push(`A${Number(appendixMatch[1])}`);
    }
  }
  return Array.from(new Set(keys));
}

function parseMainTexFrames(tex) {
  const frames = [];
  const source = String(tex || "");
  const beginMarker = "\\begin{frame}";
  const endMarker = "\\end{frame}";
  let cursor = 0;

  while (cursor < source.length) {
    const beginIndex = source.indexOf(beginMarker, cursor);
    if (beginIndex === -1) break;
    let headerIndex = beginIndex + beginMarker.length;
    while (/\s/.test(source[headerIndex] || "")) headerIndex++;

    if (source[headerIndex] === "<") {
      const grp = readBalancedTexGroup(source, headerIndex, "<", ">");
      if (grp) headerIndex = grp.endIndex;
      while (/\s/.test(source[headerIndex] || "")) headerIndex++;
    }
    let optionText = "";
    if (source[headerIndex] === "[") {
      const grp = readBalancedTexGroup(source, headerIndex, "[", "]");
      if (grp) {
        optionText = grp.value;
        headerIndex = grp.endIndex;
        while (/\s/.test(source[headerIndex] || "")) headerIndex++;
      }
    }
    let title = "";
    if (source[headerIndex] === "{") {
      const grp = readBalancedTexGroup(source, headerIndex, "{", "}");
      if (grp) {
        title = grp.value;
        headerIndex = grp.endIndex;
      }
    }

    const endIndex = source.indexOf(endMarker, headerIndex);
    if (endIndex === -1) break;
    const body = source.slice(headerIndex, endIndex);

    DISPLAY_EQ_RE.lastIndex = 0;
    const displayEquationCount = (body.match(DISPLAY_EQ_RE) || []).length;
    const taggedEquationKeys = frameTaggedEquationKeys(body);
    const taggedEquationCount = taggedEquationKeys.length;
    const visibleEquationSignalCount = Math.max(displayEquationCount, taggedEquationCount);

    const frameFull = source.slice(beginIndex, endIndex + endMarker.length);
    const label = extractFrameLabelFromOptions(optionText);

    // Find \begin{aligned} specifically (not counted by displayEq regex)
    const alignedCount = (body.match(/\\begin\{aligned\*?\}/g) || []).length;
    // Find inline display-style math: $\displaystyle ... \tag{X}$
    const inlineDisplayMathWithTag = (body.match(/\$\\displaystyle.+?\\tag\{[^}]+\}.+?\$/gs) || []).length;
    // Detect equations using \begin{aligned} inside $...$ pattern
    const inlineAlignedEq = /(?<!\\)\$(?:\\displaystyle\s*)?\\begin\{aligned\*?\}/.test(body);

    frames.push({
      index: frames.length,
      label,
      title: title.slice(0, 80),
      displayEquationCount,
      taggedEquationCount,
      taggedEquationKeys,
      visibleEquationSignalCount,
      alignedCount,
      inlineDisplayMathWithTag,
      inlineAlignedEq,
      bodyLength: body.length,
      bodyPreview: body.slice(0, 200).replace(/\s+/g, " "),
    });

    cursor = endIndex + endMarker.length;
  }
  return frames;
}

// ---------------------------------------------------------------------------
// Slide helpers
// ---------------------------------------------------------------------------

function slideIdFromPlan(slide) {
  return String(slide?.slide_id ?? slide?.id ?? "").trim();
}

function equationBlocksFromSlide(slide) {
  if (!isPlainObject(slide)) return [];
  // Check slide.equation_blocks
  const direct = Array.isArray(slide.equation_blocks) ? slide.equation_blocks : [];
  if (direct.length > 0) return direct;
  // Check blocks[].equation_blocks or blocks with type=equation
  const blocks = Array.isArray(slide.blocks) ? slide.blocks : [];
  return blocks.filter((b) => {
    if (!isPlainObject(b)) return false;
    const type = String(b.type || "").toLowerCase();
    return type === "equation" || type === "equation_block" || type === "math";
  });
}

// ---------------------------------------------------------------------------
// Equation coverage normalization
// ---------------------------------------------------------------------------

function canonicalizeEquationCoverageEntries(rawCoverage) {
  if (!Array.isArray(rawCoverage)) return [];
  return rawCoverage.map((entry) => {
    if (!isPlainObject(entry)) return entry;
    const sourceLabel = entry.source_label || entry.label || "";
    const numbers = entry.equation_numbers || entry.numbers || entry.equations || [];
    const slideIds = entry.slide_ids || [entry.slide_id].filter(Boolean);
    const status = entry.status || "covered";
    const sourceParagraphIds = entry.source_paragraph_ids || [];
    return {
      source_label: String(sourceLabel).trim(),
      equation_numbers: Array.isArray(numbers) ? numbers.map(String) : [String(numbers)],
      slide_ids: Array.isArray(slideIds) ? slideIds.map(String).filter(Boolean) : [],
      status: String(status).trim().toLowerCase(),
      source_paragraph_ids: Array.isArray(sourceParagraphIds) ? sourceParagraphIds.map(String) : [],
      coverage_stage: entry.coverage_stage || "",
      notes: entry.notes || "",
    };
  });
}

// ---------------------------------------------------------------------------
// Frame lookup (matching frame label → slide_id)
// ---------------------------------------------------------------------------

function buildFrameLookup(frames) {
  const byLabel = new Map();
  for (const frame of frames) {
    if (!isPlainObject(frame)) continue;
    const labels = [frame.label].filter(Boolean);
    for (const label of labels) {
      const key = normalizeFrameLabel(label);
      if (key && !byLabel.has(key)) {
        byLabel.set(key, frame);
      }
    }
  }
  return { frames, byLabel };
}

// ---------------------------------------------------------------------------
// Main scanner logic
// ---------------------------------------------------------------------------

function scanEquationCoverage(taskDir) {
  const analysisPath = path.join(taskDir, "analysis.json");
  const slidesPath = path.join(taskDir, "slides.json");
  const mainTexPath = path.join(taskDir, "main.tex");

  const analysis = readJsonFile(analysisPath);
  const slidesDoc = readJsonFile(slidesPath);
  const tex = fs.existsSync(mainTexPath) ? fs.readFileSync(mainTexPath, "utf8") : null;

  const report = {
    task_dir: taskDir,
    files: {
      "analysis.json": !!analysis,
      "slides.json": !!slidesDoc,
      "main.tex": !!tex,
    },
    summary: {},
    frames: [],
    coverage_entries: [],
    diagnostics: [],
  };

  // Parse main.tex frames
  if (tex) {
    report.frames = parseMainTexFrames(tex);
  }

  const frameLookup = buildFrameLookup(report.frames);

  // Collect equation_coverage from both sources
  const slidesEqCov = Array.isArray(slidesDoc?.equation_coverage) ? slidesDoc.equation_coverage : [];
  const analysisEqCov = Array.isArray(analysis?.equation_coverage) ? analysis.equation_coverage : [];
  const rawCoverage = slidesEqCov.length > 0 ? slidesEqCov : analysisEqCov;
  const coverage = canonicalizeEquationCoverageEntries(rawCoverage);

  // Build slide index
  const slides = Array.isArray(slidesDoc?.slides) ? slidesDoc.slides :
    (Array.isArray(slidesDoc?.slide_plan) ? slidesDoc.slide_plan : []);
  const slideIndex = new Map();
  for (const slide of slides) {
    const sid = slideIdFromPlan(slide);
    if (sid) slideIndex.set(normalizeFrameLabel(sid), slide);
  }

  // Analyze each coverage entry
  const totalEntries = coverage.length;
  let missingFrames = 0;
  let missingTags = 0;
  let missingBlocks = 0;
  let displayEqZero = 0;
  let inlineAlignedCount = 0;
  const equationIssues = [];

  for (const entry of coverage) {
    const status = entry.status;
    if (!["covered", "covered_with_ocr_gap_note", "inline_integrated", "standalone_supplement", "partial"].includes(status)) continue;

    const label = entry.source_label;
    const numbers = entry.equation_numbers;
    const slideIds = entry.slide_ids;

    if (slideIds.length === 0) {
      equationIssues.push({
        label,
        issue: "no_slide_ids",
        detail: `equation_coverage marks ${label} as ${status} but has no slide_ids`,
      });
      continue;
    }

    for (const slideId of slideIds) {
      const normalized = normalizeFrameLabel(slideId);
      const frame = frameLookup.byLabel.get(normalized);
      const slide = slideIndex.get(normalized);

      const diag = {
        label,
        slide_id: slideId,
        status,
        equation_numbers: numbers,
        frame_found: !!frame,
        frame_index: frame ? frame.index : -1,
        frame_label: frame ? frame.label : null,
        frame_display_eq_count: frame ? frame.displayEquationCount : 0,
        frame_tagged_eq_count: frame ? frame.taggedEquationCount : 0,
        frame_tagged_keys: frame ? frame.taggedEquationKeys : [],
        frame_visible_eq_signal: frame ? frame.visibleEquationSignalCount : 0,
        frame_inline_aligned_eq: frame ? frame.inlineAlignedEq : false,
        frame_aligned_count: frame ? frame.alignedCount : 0,
        slide_has_equation_blocks: slide ? equationBlocksFromSlide(slide).length > 0 : false,
        slide_equation_block_count: slide ? equationBlocksFromSlide(slide).length : 0,
      };

      // Check for issues
      const issues = [];
      if (!frame) {
        issues.push("frame_not_found");
        missingFrames++;
      }
      if (frame && frame.visibleEquationSignalCount === 0) {
        issues.push("no_visible_equation_block");
        displayEqZero++;
      }
      if (frame && frame.displayEquationCount === 0 && frame.alignedCount > 0) {
        issues.push("uses_aligned_not_align");
        inlineAlignedCount++;
      }

      // Check tagged equation number matching. Normalize coverage labels such as
      // "Eq. (2.1)" into the same canonical keys used by \tag{2.1} parsing.
      if (frame && numbers.length > 0) {
        const frameKeys = new Set(frame.taggedEquationKeys.map(String));
        const missing = [];
        for (const rawNumber of numbers) {
          const canonical = extractEquationNumbersFromText(rawNumber);
          const candidates = canonical.length > 0 ? canonical : [String(rawNumber)];
          if (!candidates.some((candidate) => frameKeys.has(String(candidate)))) {
            missing.push(String(rawNumber));
          }
        }
        if (missing.length > 0) {
          issues.push(`missing_tags:${missing.join(",")}`);
          missingTags += missing.length;
        }
      }

      // Check slides.json equation_blocks
      if (slide && equationBlocksFromSlide(slide).length === 0 && numbers.length > 0 && frame && frame.alignedCount > 0) {
        issues.push("no_equation_blocks_in_slides_json");
        missingBlocks++;
      }

      diag.issues = issues;
      diag.recommended_action = recommendAction(diag);

      equationIssues.push(diag);
    }
  }

  // Summary
  report.summary = {
    total_coverage_entries: totalEntries,
    covered_entries_analyzed: equationIssues.length,
    frames_total: report.frames.length,
    frames_with_labels: report.frames.filter((f) => f.label).length,
    frames_with_visible_eq: report.frames.filter((f) => f.visibleEquationSignalCount > 0).length,
    frames_with_aligned: report.frames.filter((f) => f.alignedCount > 0).length,
    frames_with_inline_display_math_tag: report.frames.filter((f) => f.inlineDisplayMathWithTag > 0).length,
    issues: {
      missing_frames: missingFrames,
      display_eq_zero: displayEqZero,
      missing_tags: missingTags,
      missing_blocks_in_slides: missingBlocks,
      uses_aligned_not_align: inlineAlignedCount,
    },
  };

  report.diagnostics = equationIssues;

  return report;
}

function recommendAction(diag) {
  const issues = diag.issues || [];
  if (issues.includes("frame_not_found")) return "create_frame_for_slide";
  if (issues.includes("no_visible_equation_block") && diag.frame_inline_aligned_eq) {
    return "convert_inline_aligned_to_display_math";
  }
  if (issues.includes("no_visible_equation_block") && !diag.frame_found) {
    return "insert_equation_into_frame";
  }
  if (issues.some((i) => i.startsWith("missing_tags"))) {
    return diag.frame_aligned_count > 0
      ? "add_tag_to_existing_aligned_equation"
      : "insert_equation_with_tag_into_frame";
  }
  if (issues.includes("uses_aligned_not_align")) {
    return "change_aligned_to_align_or_add_to_validator_regex";
  }
  return "none";
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

if (require.main === module) {
  const taskDir = process.argv[2];
  if (!taskDir) {
    console.error("Usage: node deck_equation_coverage_scanner.js <task-dir>");
    process.exit(1);
  }
  const report = scanEquationCoverage(taskDir);
  console.log(JSON.stringify(report, null, 2));
}

module.exports = { scanEquationCoverage, parseMainTexFrames, canonicalizeEquationCoverageEntries };


path: scripts/deck_instruction_leak_repair.js
content: #!/usr/bin/env node
/**
 * deck_instruction_leak_repair.js — Deterministic instruction-leak repair
 *
 * Strips LLM thinking/instruction patterns that leak into Beamer frame prose.
 * These are patterns like "完整保留图X图片", "这页共同保证...不被摘要替代",
 * "图示完整保留，讲解时先读坐标轴..." etc.
 *
 * Usage:
 *   node deck_instruction_leak_repair.js <output-directory>          # fix (default)
 *   node deck_instruction_leak_repair.js <output-directory> --check  # check only
 *   node deck_instruction_leak_repair.js <output-directory> --dry-run # report without writing
 */

"use strict";

const fs = require("node:fs");
const path = require("node:path");

// ─── Pattern definitions ────────────────────────────────────────────────────

/**
 * Each rule: { name, regex, action, note }
 * action: "delete-line" | "delete-block" | "unwrap" (keep content after separator)
 * regex is applied per-line against a frame's inner body.
 */
const RULES = [
  // [A] "完整保留 X 图片/表 X" — delete the whole bullet line
  {
    name: "baoliu-bullet",
    regex: /^\s*\\item\s+完整保留\s*(图|表|附录)\s*[0-9A-Za-z一二三四五六七八九十]*(图片|表格)?\s*$/,
    action: "delete-line",
    note: "完整保留图/表/附录的指令行",
  },
  // [A-ext] "完整保留 ... 不以摘要替代"
  {
    name: "baoliu-no-summary",
    regex: /^\s*\\item\s.*完整保留.*不以摘要替代\s*$/,
    action: "delete-line",
    note: "完整保留+不以摘要替代",
  },
  // [A-ext2] "完整保留 ... ODE" / "完整保留 ... PDE"
  {
    name: "baoliu-equation-system",
    regex: /^\s*\\item\s+完整保留.*(ODE|PDE|指数仿射|指数二次)\s*$/,
    action: "delete-line",
    note: "完整保留方程系统/猜测式",
  },
  // [A-ext3] Broad catch: any \item starting with "完整保留"
  {
    name: "baoliu-generic",
    regex: /^\s*\\item\s+完整保留/,
    action: "delete-line",
    note: "通用完整保留行",
  },
  // [B] "这页…共同保证…不被摘要替代"
  {
    name: "gongtong-baozheng",
    regex: /^\s*\\item\s+这页.*共同保证.*不被摘要替代\s*$/,
    action: "delete-line",
    note: "共同保证不被摘要替代",
  },
  // [C] "该页用于…不和…混合"
  {
    name: "gaiye-yongyu",
    regex: /^\s*\\item\s+该页用于.*(不和|不参与|不涉及)/,
    action: "delete-line",
    note: "该页用于公式验证/说明",
  },
  // [D] "\small 图示完整保留，讲解时…"
  {
    name: "tushi-small",
    regex: /^\s*\\small\s+图示完整保留.*讲解时/,
    action: "delete-line",
    note: "图示完整保留讲解提示",
  },
  // [D-ext] Similar small-text instruction notes
  {
    name: "tushi-small-2",
    regex: /^\s*\\small\s+读图重点/,
    action: "delete-line",
    note: "small 读图重点行",
  },
  // [E] "读图/读表重点：" — unwrap (keep content after colon)
  {
    name: "du-tu-zhongdian",
    regex: /^\s*\\item\s+读[图表]重点[：:]\s*(.+)/,
    action: "unwrap",
    replacement: "\\item $1",
    note: "读图/读表重点：保留内容",
  },
  // [E-2] "读图时/读表时/读图要/读表要" — delete presenter cues
  {
    name: "du-tu-biao-cue",
    regex: /^\s*\\item\s*读[图表](?:时|要|的)/u,
    action: "delete-line",
    note: "读图/读表时/要的讲解提示",
  },
  // [F] "这里先定义记号，后续…"
  {
    name: "zheli-xian-dingyi",
    regex: /^\s*\\item\s+这里先定义记号.*后续/,
    action: "delete-line",
    note: "这里先定义记号",
  },
  // [G] "这页/该页/本页 …" — page-level meta-instructions for slide authors
  {
    name: "gaiye-meta",
    regex: /^\s*\\item\s+(?:这页|该页|本页)/u,
    action: "delete-line",
    note: "这页/该页/本页元指令",
  },
  // [H] "后续连续公式页拆分…避免压缩…" — planning notes
  {
    name: "planning-split",
    regex: /^\s*\\item\s+后续.*(?:连续.*(?:公式|页)|拆分.*页|避免.*(?:压缩|省略|删除|隐藏))/u,
    action: "delete-line",
    note: "后续页拆分/压缩规划说明",
  },
];

// ─── Core logic ──────────────────────────────────────────────────────────────

/**
 * Process a list of lines representing the content BETWEEN \begin{frame} and \end{frame}.
 * Returns { lines, removed: [{lineIndex, rule, original}] }
 */
function processFrameBody(lines) {
  const result = { lines: [], removed: [] };

  for (let i = 0; i < lines.length; i++) {
    const original = lines[i];
    let matched = false;

    for (const rule of RULES) {
      const m = original.match(rule.regex);
      if (!m) continue;

      if (rule.action === "delete-line") {
        result.removed.push({ lineIndex: i, rule: rule.name, original: original.trim() });
        matched = true;
        break;
      }

      if (rule.action === "unwrap" && rule.replacement) {
        const replacement = rule.replacement.replace(/\$1/g, (m[1] || "").trim());
        result.lines.push(replacement);
        result.removed.push({
          lineIndex: i,
          rule: rule.name,
          original: original.trim(),
          replaced: replacement.trim(),
        });
        matched = true;
        break;
      }
    }

    if (!matched) {
      result.lines.push(original);
    }
  }

  return result;
}

/**
 * Scan and optionally repair instruction leaks in main.tex.
 */
function repairInstructionLeaks(outputDirectory, options = {}) {
  const dryRun = options.dryRun !== false;
  const mainPath = path.join(outputDirectory, "main.tex");

  const report = {
    summary: {
      output_directory: outputDirectory,
      dry_run: dryRun,
      main_tex_modified: false,
      total_removed: 0,
      rules_hit: {},
    },
    details: [],
  };

  if (!fs.existsSync(mainPath)) {
    report.summary.error = "main.tex not found";
    return report;
  }

  const original = fs.readFileSync(mainPath, "utf8");

  // Process frame by frame
  const framePattern = /\\begin\{frame\}[\s\S]*?\\end\{frame\}/g;
  const processed = original.replace(framePattern, (frameBlock) => {
    const lines = frameBlock.split("\n");
    const bodyLines = [];
    let inFrame = false;
    let frameHeader = "";
    let frameFooter = "";

    // Split into header, body, footer
    const headerIdx = lines.findIndex((l) => /\\begin\{frame\}/.test(l));
    const footerIdx = lines.findIndex((l, i) => i > headerIdx && /\\end\{frame\}/.test(l));

    if (headerIdx < 0) return frameBlock;

    frameHeader = lines.slice(0, headerIdx + 1).join("\n");

    if (footerIdx > headerIdx) {
      frameFooter = "\n" + lines.slice(footerIdx).join("\n");
    }

    bodyLines.length = 0;
    for (let i = headerIdx + 1; i < (footerIdx > headerIdx ? footerIdx : lines.length); i++) {
      bodyLines.push(lines[i]);
    }

    const result = processFrameBody(bodyLines);

    // Record details
    if (result.removed.length > 0) {
      report.details.push({
        frame_header: lines[headerIdx]?.trim().slice(0, 120),
        removed: result.removed,
      });
      for (const r of result.removed) {
        report.summary.rules_hit[r.rule] = (report.summary.rules_hit[r.rule] || 0) + 1;
        report.summary.total_removed++;
      }
    }

    if (result.removed.length === 0) return frameBlock;

    // Reconstruct frame
    const newBody = result.lines.join("\n");
    return frameHeader + "\n" + newBody + frameFooter;
  });

  if (report.summary.total_removed > 0 && !dryRun) {
    fs.writeFileSync(mainPath, processed, "utf8");
    report.summary.main_tex_modified = true;
  }

  return report;
}

/**
 * Generate a patch description for the orchestrator.
 * Returns a flat object the orchestrator can use to write checkpoint notes.
 */
function generateOrchestratorPatch(report) {
  return {
    instruction_leak_repair: {
      applied: report.summary.total_removed > 0 && report.summary.main_tex_modified,
      total_removed: report.summary.total_removed,
      rules_hit: report.summary.rules_hit,
      dry_run: report.summary.dry_run,
    },
  };
}

// ─── CLI ─────────────────────────────────────────────────────────────────────

if (require.main === module) {
  const args = process.argv.slice(2);
  const checkMode = args.includes("--check");
  const dryRun = args.includes("--dry-run") || (process.env.DRY_RUN === "true");
  const outputDir = args.find((a) => !a.startsWith("--"));

  if (!outputDir) {
    console.error("Usage: deck_instruction_leak_repair.js <output-directory> [--check] [--dry-run]");
    process.exit(1);
  }

  const options = {
    dryRun: checkMode || dryRun,
  };

  const report = repairInstructionLeaks(outputDir, options);

  if (checkMode) {
    console.log(JSON.stringify(report, null, 2));
  } else if (dryRun) {
    console.log("[DRY RUN] Would remove", report.summary.total_removed, "instruction-leak lines");
    console.log(JSON.stringify(report, null, 2));
  } else {
    console.log("Removed", report.summary.total_removed, "instruction-leak lines");
    if (report.summary.total_removed > 0) {
      console.log("Rules hit:", JSON.stringify(report.summary.rules_hit));
    }
  }

  process.exit(0);
}

module.exports = { repairInstructionLeaks, generateOrchestratorPatch };


path: scripts/deck_language_render_guards.js
content: "use strict";

const fs = require("node:fs");
const { spawnSync } = require("node:child_process");

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function uniqueStrings(values) {
  const seen = new Set();
  const result = [];
  for (const value of Array.isArray(values) ? values : []) {
    const text = String(value || "").trim();
    if (!text || seen.has(text)) continue;
    seen.add(text);
    result.push(text);
  }
  return result;
}

function countCjkChars(text) {
  return (String(text || "").match(/[\u3400-\u9FFF]/gu) || []).length;
}

function countAsciiLetters(text) {
  return (String(text || "").match(/[A-Za-z]/g) || []).length;
}

function countEnglishWords(text) {
  return (String(text || "").match(/\b[A-Za-z][A-Za-z'-]{1,}\b/g) || []).length;
}

function paragraphLedgerSummaryText(entry) {
  return entry?.summary_sentence ?? entry?.summary ?? entry?.summary_zh ?? entry?.summary_text ?? "";
}

function stripMathAndTexForLanguageStats(text) {
  return String(text || "")
    .replace(/\$[^$]*\$/g, " ")
    .replace(/\\\[[\s\S]*?\\\]/g, " ")
    .replace(/\\\([\s\S]*?\\\)/g, " ")
    .replace(/\\begin\{(?:equation|align|gather|multline)\*?\}[\s\S]*?\\end\{(?:equation|align|gather|multline)\*?\}/gi, " ")
    .replace(/\\[A-Za-z]+\*?(?:\s*\[[^\]]*\])?(?:\s*\{[^{}]*\})?/g, " ")
    .replace(/\\./g, " ")
    .replace(/\b(?:Eq|Eqs|Fig|Figs|Table|Tables|PIDO|PINN|PDE|ODE|CCS|CCUS|CO2|NPV|DCF|ROC|OCR|kWh|MWh|GWh|MW|GW|USD|RMB)\b/gi, " ")
    .replace(/[0-9_{}^=+\-*/.,;:()[\]|<>$]/g, " ");
}

function mostlyEnglishNaturalLanguageStats(text) {
  const cleaned = stripMathAndTexForLanguageStats(text);
  const cjkChars = countCjkChars(cleaned);
  const asciiLetters = countAsciiLetters(cleaned);
  const englishWords = countEnglishWords(cleaned);
  const asciiToCjkRatio = asciiLetters / Math.max(1, cjkChars);
  const hasSourceCopyCue = /\b(?:where|whereas|currently|as\s+the|the\s+model|the\s+paper|this\s+paper|we\s+(?:assume|consider|show|derive)|is\s+existing|is\s+the|are\s+the|denotes?|represents?|respectively|given\s+by|defined\s+as|can\s+be\s+written)\b/i.test(cleaned);
  const reject = (
    (asciiLetters >= 64 && englishWords >= 8 && cjkChars < 16 && asciiToCjkRatio >= 2.8)
    || (hasSourceCopyCue && asciiLetters >= 36 && englishWords >= 5 && cjkChars < 14 && asciiToCjkRatio >= 2.2)
  );
  return {
    reject,
    cjkChars,
    asciiLetters,
    englishWords,
    asciiToCjkRatio,
    hasSourceCopyCue,
  };
}

function compactPreview(text, maxLength = 96) {
  const value = String(text || "").replace(/\s+/g, " ").trim();
  if (value.length <= maxLength) return value;
  return `${value.slice(0, maxLength - 1)}...`;
}

function paragraphLedgerLanguageDiagnostics(paragraphLedger, options = {}) {
  const errors = [];
  const maxErrors = Number.isFinite(Number(options.maxErrors)) ? Math.max(1, Number(options.maxErrors)) : 8;
  const ledger = Array.isArray(paragraphLedger) ? paragraphLedger : [];
  for (const entry of ledger) {
    if (!isPlainObject(entry)) continue;
    const summary = String(paragraphLedgerSummaryText(entry) || "").trim();
    if (!summary) continue;
    const stats = mostlyEnglishNaturalLanguageStats(summary);
    if (!stats.reject) continue;
    const paragraphId = String(entry.paragraph_id ?? entry.id ?? "").trim() || "unknown";
    errors.push(`paragraph_ledger ${paragraphId} summary_sentence appears to copy English source prose instead of a Chinese summary; move raw English to source_excerpt and rewrite summary_sentence in Chinese: "${compactPreview(summary)}"`);
    if (errors.length >= maxErrors) {
      errors.push(`paragraph_ledger has more English-source-copy summary_sentence failures; showing first ${maxErrors}`);
      break;
    }
  }
  return errors;
}

function extractFrameBodies(tex) {
  const source = String(tex || "");
  const bodies = [];
  const beginMarker = "\\begin{frame}";
  const endMarker = "\\end{frame}";
  let cursor = 0;
  while (cursor < source.length) {
    const beginIndex = source.indexOf(beginMarker, cursor);
    if (beginIndex === -1) break;
    const headerEnd = source.indexOf("\n", beginIndex);
    const bodyStart = headerEnd === -1 ? beginIndex + beginMarker.length : headerEnd + 1;
    const endIndex = source.indexOf(endMarker, bodyStart);
    if (endIndex === -1) break;
    bodies.push(source.slice(bodyStart, endIndex));
    cursor = endIndex + endMarker.length;
  }
  return bodies;
}

function extractLatexItemTexts(tex) {
  const items = [];
  for (const body of extractFrameBodies(tex)) {
    const itemPattern = /\\item(?:\s*<[^>]*>)?(?:\s*\[[^\]]*\])?\s*([\s\S]*?)(?=\n\s*\\item\b|\n\s*\\end\{(?:itemize|enumerate|description)\}|$)/g;
    for (const match of body.matchAll(itemPattern)) {
      const item = String(match[1] || "")
        .replace(/\\begin\{(?:equation|align|gather|multline)\*?\}[\s\S]*?\\end\{(?:equation|align|gather|multline)\*?\}/gi, " ")
        .replace(/\s+/g, " ")
        .trim();
      if (item) items.push(item);
    }
  }
  return items;
}

function literalLatexLeakPatternDiagnostics(text, sourceLabel) {
  const source = String(text || "");
  if (!source) return [];
  const checks = [
    {
      pattern: /\\textbackslash(?:\{\}|\\\{\\\})?/i,
      message: `${sourceLabel} contains visible \\textbackslash{} leakage from blanket TeX escaping`,
    },
    {
      pattern: /\\textasciicircum(?:\{\}|\\\{\\\})?/i,
      message: `${sourceLabel} contains visible \\textasciicircum{} leakage from blanket TeX escaping`,
    },
    {
      pattern: /\\\$\s*[A-Za-z]\\_/,
      message: `${sourceLabel} contains escaped inline math like \\$P\\_..., so LaTeX math will render as literal text`,
    },
    {
      pattern: /\bwhere\s+\\?\$/i,
      message: `${sourceLabel} contains an English raw-math prose fragment such as "where $...", which must be rewritten as Chinese prose plus equation_blocks`,
    },
    {
      pattern: /\\\{\\\}\s*[A-Za-z]+/,
      message: `${sourceLabel} contains escaped command braces such as \\{}mathrm/\\{}gamma, indicating LaTeX was rendered as text`,
    },
    {
      pattern: /\\ensuremath\s*\{/,
      message: `${sourceLabel} contains \\ensuremath{...} in visible prose; move math symbols into equation_blocks and rewrite visible bullets as Chinese natural-language explanation`,
    },
  ];
  return checks.filter((check) => check.pattern.test(source)).map((check) => check.message);
}

function latexItemLanguageDiagnostics(tex, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.tex");
  const errors = [];
  const maxErrors = Number.isFinite(Number(options.maxErrors)) ? Math.max(1, Number(options.maxErrors)) : 6;
  for (const item of extractLatexItemTexts(tex)) {
    const stats = mostlyEnglishNaturalLanguageStats(item);
    if (!stats.reject) continue;
    errors.push(`${sourceLabel} contains a mostly-English visible bullet; visible Beamer prose should be Chinese and raw source excerpts should stay out of slide bullets: "${compactPreview(item)}"`);
    if (errors.length >= maxErrors) {
      errors.push(`${sourceLabel} has more mostly-English visible bullet failures; showing first ${maxErrors}`);
      break;
    }
  }
  return errors;
}

function latexItemInlineMathDiagnostics(tex, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.tex");
  const errors = [];
  const maxErrors = Number.isFinite(Number(options.maxErrors)) ? Math.max(1, Number(options.maxErrors)) : 12;
  for (const item of extractLatexItemTexts(tex)) {
    const hasEnsuremath = /\\ensuremath(?:\s*\[[^\]]*\])?(?:\s*\{[^}]*\})/.test(item);
    if (!hasEnsuremath) continue;
    errors.push(`${sourceLabel} visible bullet contains \\ensuremath{...} bypass; move symbols into equation_blocks and write the bullet as Chinese natural-language explanation: "${compactPreview(item)}"`);
    if (errors.length >= maxErrors) {
      errors.push(`${sourceLabel} has more \\ensuremath-bypass failures; showing first ${maxErrors}`);
      break;
    }
  }
  return errors;
}

// Non-blocking: $...$ inline math is legitimate LaTeX.  Flag as informational warning
// so the programmer can still see it, but do NOT treat these as repair-blocking errors.
function latexItemInlineMathWarnings(tex, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.tex");
  const warnings = [];
  const maxWarnings = Number.isFinite(Number(options.maxErrors)) ? Math.max(1, Number(options.maxErrors)) : 12;
  for (const item of extractLatexItemTexts(tex)) {
    const hasRawInlineMath = /\$[^$]+\$/.test(item);
    if (!hasRawInlineMath) continue;
    warnings.push(`${sourceLabel} visible bullet contains $...$ inline math (informational — allowed, not blocking): "${compactPreview(item)}"`);
    if (warnings.length >= maxWarnings) {
      warnings.push(`${sourceLabel} has more $...$ inline-math bullets; showing first ${maxWarnings}`);
      break;
    }
  }
  return warnings;
}

function beamerMainTexLanguageAndLatexLeakDiagnostics(mainTex, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.tex");
  return uniqueStrings([
    ...literalLatexLeakPatternDiagnostics(mainTex, sourceLabel),
    ...latexItemLanguageDiagnostics(mainTex, { sourceLabel }),
    ...latexItemInlineMathDiagnostics(mainTex, { sourceLabel }),
  ]);
}

// Non-blocking: returns $...$ inline-math warnings separate from blocking errors.
function beamerMainTexLanguageAndLatexLeakWarnings(mainTex, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.tex");
  return uniqueStrings([
    ...latexItemInlineMathWarnings(mainTex, { sourceLabel }),
  ]);
}

function renderedTextLatexLeakDiagnostics(renderedText, options = {}) {
  const sourceLabel = String(options.sourceLabel || "main.pdf text");
  const text = String(renderedText || "");
  if (!text.trim()) return [];
  const errors = [
    ...literalLatexLeakPatternDiagnostics(text, sourceLabel),
  ];
  if (/\$[A-Za-z]_\{?[A-Za-z0-9]/.test(text) || /\\(?:mathrm|gamma|alpha|beta|theta|frac|sum|int)\b/.test(text)) {
    errors.push(`${sourceLabel} contains literal math markup after rendering; formulas should render as math, not dollar/backslash text`);
  }
  for (const line of text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
    const stats = mostlyEnglishNaturalLanguageStats(line);
    if (stats.reject) {
      errors.push(`${sourceLabel} contains a mostly-English rendered prose line; visible Beamer prose should be Chinese: "${compactPreview(line)}"`);
      break;
    }
  }
  return uniqueStrings(errors);
}

function extractPdfTextIfAvailable(pdfPath, options = {}) {
  const minBytes = Number.isFinite(Number(options.minBytes)) ? Math.max(0, Number(options.minBytes)) : 512;
  try {
    if (!pdfPath || !fs.existsSync(pdfPath)) return "";
    const stat = fs.statSync(pdfPath);
    if (!stat.isFile() || stat.size < minBytes) return "";
    const result = spawnSync("pdftotext", ["-layout", String(pdfPath), "-"], {
      encoding: "utf8",
      timeout: 5000,
      maxBuffer: 2 * 1024 * 1024,
    });
    if (result.status !== 0 || result.error) return "";
    return String(result.stdout || "");
  } catch {
    return "";
  }
}

module.exports = {
  beamerMainTexLanguageAndLatexLeakDiagnostics,
  beamerMainTexLanguageAndLatexLeakWarnings,
  countAsciiLetters,
  countCjkChars,
  extractPdfTextIfAvailable,
  mostlyEnglishNaturalLanguageStats,
  paragraphLedgerLanguageDiagnostics,
  latexItemInlineMathDiagnostics,
  latexItemInlineMathWarnings,
  renderedTextLatexLeakDiagnostics,
};


path: scripts/deck_notation_coverage_repair.js
content: #!/usr/bin/env node
/**
 * deck_notation_coverage_repair.js — Deterministic notation_coverage repair
 *
 * Wraps slide_symbol_scanner.js for first_defined/used_slide_ids fixup, then
 * handles generic-index entries (e.g. \alpha_i) that cover specific subscripts
 * (\alpha_1, \alpha_2) but can't be matched literally in frame text.
 *
 * Usage:
 *   node deck_notation_coverage_repair.js <output-directory> [--dry-run]
 */

"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");

function repairNotationCoverage(outputDirectory, options = {}) {
  const dryRun = options.dryRun !== false;
  const report = {
    summary: {
      output_directory: outputDirectory,
      dry_run: dryRun,
      scanner_applied: false,
      scanner_fixes: 0,
      generic_symbols_handled: 0,
      total_applied: 0,
      slides_json_modified: false,
    },
    scanner: null,
    generic: [],
  };

  const slidesPath = path.join(outputDirectory, "slides.json");
  if (!fs.existsSync(slidesPath)) {
    report.summary.error = "slides.json not found";
    return report;
  }

  const scannerScript = path.join(__dirname, "slide_symbol_scanner.js");
  const argv = [scannerScript, dryRun ? "--check" : "--fix", slidesPath];

  let scanner;
  try {
    scanner = spawnSync("node", argv, { encoding: "utf8", timeout: 60000 });
  } catch (err) {
    report.summary.error = `scanner_exec_failed: ${err.message}`;
    return report;
  }

  report.scanner = {
    stdout: (scanner.stdout || "").trim(),
    stderr: (scanner.stderr || "").trim(),
    exitCode: scanner.status ?? null,
  };

  if (scanner.status !== 0) {
    report.summary.error = `scanner_exit_code=${scanner.status}`;
    return report;
  }

  report.summary.scanner_applied = true;
  const scannerOutput = scanner.stdout || "";
  const fixMatches = scannerOutput.match(/(\d+)\s*total\s*fix/i);
  report.summary.scanner_fixes = fixMatches ? Number(fixMatches[1]) : 0;

  // ─── Handle generic-index entries ──────────────────────────────
  // Run detection even in dry-run to report what would change
  if (fs.existsSync(slidesPath)) {
    let slidesDoc;
    try {
      slidesDoc = JSON.parse(fs.readFileSync(slidesPath, "utf8"));
    } catch {
      // fall through to return
    }
    if (slidesDoc) {
      const notationCoverage = Array.isArray(slidesDoc.notation_coverage)
        ? slidesDoc.notation_coverage
        : [];

      if (notationCoverage.length > 0) {
        const genericHandled = handleGenericIndexEntries(notationCoverage, slidesDoc);
        report.summary.generic_symbols_handled = genericHandled;
        report.generic = genericHandled > 0
          ? notationCoverage
              .filter((e) => e && typeof e === "object" && e.status === "generic_placeholder")
              .map((e) => ({
                symbol: e.symbol,
                status: e.status,
                notes: e.notes,
              }))
          : [];

        if (!dryRun && genericHandled > 0) {
          slidesDoc.notation_coverage = notationCoverage;
          slidesDoc.notation_coverage_last_fixed_by = "deck_notation_coverage_repair";
          slidesDoc.notation_coverage_last_fixed_at = new Date().toISOString();
          fs.writeFileSync(slidesPath, JSON.stringify(slidesDoc, null, 2), "utf8");
          report.summary.slides_json_modified = true;
        }
      }
    }
  }

  report.summary.total_applied = report.summary.scanner_fixes + report.summary.generic_symbols_handled;
  return report;
}

/**
 * Generic-index symbols like \alpha_i, \beta_i carry semantic meaning ("any
 * family member") but cannot be matched literally against frame text where
 * only \alpha_1, \alpha_2 etc. appear. We mark these as status=generic_placeholder
 * so the validator skips literal visibility checks while preserving audit.
 */
function handleGenericIndexEntries(notationCoverage, slidesDoc) {
  const slides = Array.isArray(slidesDoc.slides) ? slidesDoc.slides : [];
  const handled = [];

  for (const entry of notationCoverage) {
    if (!entry || typeof entry !== "object") continue;
    const symbol = String(entry.symbol || "").trim();
    if (!symbol) continue;

    // Detect generic index patterns: \alpha_i, \beta_{i}, x_n, etc.
    const genericMatch = symbol.match(
      /^(?:\\([A-Za-z]+)|([A-Za-z]+)|([αβγδθφνμξεωΩΔ]))_\{?(i|j|k|n|m)\}?$/
    );
    if (!genericMatch) continue;

    const baseName = genericMatch[1] || genericMatch[2] || genericMatch[3];
    const genericIndex = genericMatch[4];

    // Find specific subscripts already tracked, e.g. \alpha_1, \alpha_2
    const specificSubscripts = notationCoverage.filter(
      (other) => other !== entry
        && other
        && typeof other === "object"
        && String(other.symbol || "").match(
          new RegExp(`^(?:\\\\${baseName}|${baseName})_\\{?(\\d+)\\}?$`)
        )
    );

    // Mark generic entry as placeholder so validator doesn't flag it
    if (specificSubscripts.length > 0 && entry.status !== "generic_placeholder") {
      const specificIds = specificSubscripts
        .flatMap((e) => Array.isArray(e.used_slide_ids) ? e.used_slide_ids : [])
        .filter(Boolean);
      entry.status = "generic_placeholder";
      entry.notes = (entry.notes ? entry.notes + "; " : "")
        + `covers specific subscripts: ${specificSubscripts.map((e) => e.symbol).join(", ")}`;
      // Inherit used_slide_ids union from specific subscripts
      if (specificIds.length > 0) {
        entry.used_slide_ids = [...new Set(specificIds.map(String))];
      }
      handled.push(symbol);
    }
  }

  return handled.length;
}

// ─── CLI ────────────────────────────────────────────────────────
function main() {
  const args = process.argv.slice(2);
  let dryRun = false;
  let outputDir = "";

  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--dry-run") dryRun = true;
    else if (!args[i].startsWith("--")) outputDir = args[i];
  }

  if (!outputDir) {
    console.error("Usage: node deck_notation_coverage_repair.js <output-directory> [--dry-run]");
    process.exit(1);
  }

  if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) {
    console.error("Directory not found:", outputDir);
    process.exit(1);
  }

  const report = repairNotationCoverage(outputDir, { dryRun });
  console.log(JSON.stringify(report, null, 2));
}

if (require.main === module) {
  main();
}

module.exports = { repairNotationCoverage, handleGenericIndexEntries };


path: scripts/deck_strategy_placeholder_repair.js
content: #!/usr/bin/env node
/**
 * deck_strategy_placeholder_repair.js — Fill planned/analysis_only/recovery
 * placeholders in overlay_strategy and audience_explanation_strategy with
 * conservative defaults.
 *
 * Usage:
 *   node deck_strategy_placeholder_repair.js <slides.json> [--dry-run]
 */

"use strict";

const fs = require("node:fs");
const path = require("node:path");

const UNRESOLVED_PATTERN = /^(planned|analysis_only|recovery|missing|blocked|partial)$/i;

function isPlainObject(v) { return Boolean(v) && typeof v === "object" && !Array.isArray(v); }

function hasUnresolvedStatus(value) {
  if (typeof value === "string") return UNRESOLVED_PATTERN.test(value.trim());
  if (isPlainObject(value)) return UNRESOLVED_PATTERN.test(String(value.status || "").trim());
  return false;
}

function classifySlide(slide, slides) {
  const slideId = String(slide?.slide_id || "");
  const title = String(slide?.title || "").toLowerCase();
  const subtitle = String(slide?.subtitle || "").toLowerCase();
  const desc = String(slide?.description || "").toLowerCase();
  const fullText = [title, subtitle, desc].join(" ");

  const eqBlocks = Array.isArray(slide?.equation_blocks) ? slide.equation_blocks : [];
  const hasDenseFormula = eqBlocks.length >= 2;
  const hasTheorem = /\b(theorem|定理|proposition|命题|lemma|引理|corollary|推论)\b/i.test(fullText);
  const hasNumerical = /(numerical|数值|calibrat|校准|parameter|参数|table|表格|implied|隐含|volatility|波动率)/i.test(fullText);
  const hasFigure = /(figure|fig|图|chart|plot)/i.test(fullText);
  const hasRisk = /(hedge|对冲|risk|风险|sensitiv|敏感度|greeks?)/i.test(fullText);
  const isMotivation = /(motivation|动机|introduct|简介|overview|概览|background|背景|literature|文献|framework|框架|problem|问题)/i.test(fullText);

  // Check if it's an appendix slide
  const isAppendix = /^s\D*[AB]\d*$/i.test(slideId) || /appendix|附录/i.test(fullText);

  // Count bullets
  const bullets = Array.isArray(slide?.bullets) ? slide.bullets : [];
  const bulletCount = bullets.length;

  let pageType = "content";
  if (isMotivation) pageType = "motivation";
  if (hasTheorem) pageType = "theorem";
  if (hasDenseFormula) pageType = "formula_dense";
  if (hasNumerical) pageType = "numerical";
  if (hasFigure && hasNumerical) pageType = "numerical_figure";
  if (isAppendix) pageType = "appendix";

  return {
    slideId,
    pageType,
    hasDenseFormula,
    hasTheorem,
    hasNumerical,
    hasFigure,
    bulletCount,
    equationCount: eqBlocks.length,
    isAppendix,
  };
}

function generateDefaultOverlayStrategy(slide, classification, slides) {
  const { pageType, hasDenseFormula, bulletCount, equationCount } = classification;

  // Conservative per-type defaults
  switch (pageType) {
    case "motivation":
      return {
        status: "covered",
        strategy: "sequential_reveal",
        overlay_count: Math.max(2, bulletCount),
        items: (slide?.bullets || []).slice(0, 5).map((_, i) => ({
          step: i + 1,
          action: "reveal",
          target: `bullet_${i + 1}`,
        })),
        rationale: "动机/背景页，逐条展开以控制观众注意力节奏",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "theorem":
      return {
        status: "covered",
        strategy: "static_full",
        overlay_count: 0,
        items: [],
        rationale: "定理页为单次揭示可读性最优，不考虑overlay拆分",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "formula_dense":
      return {
        status: "covered",
        strategy: "block_sequential",
        overlay_count: equationCount,
        items: Array.from({ length: equationCount }, (_, i) => ({
          step: i + 1,
          action: "reveal",
          target: `equation_block_${i + 1}`,
        })),
        rationale: "公式密集页逐块展开，每个equation block单独揭示以配合讲解节奏",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "numerical":
    case "numerical_figure":
      return {
        status: "covered",
        strategy: "mixed_sequential",
        overlay_count: Math.max(2, bulletCount + equationCount),
        items: [],
        rationale: "数值结果页混合图和/或表格，按结果顺序逐组展示",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "appendix":
      return {
        status: "covered",
        strategy: "static_full",
        overlay_count: 0,
        items: [],
        rationale: "附录页一般为一次性引用，无需动态overlay；若正文需要调用，用againframe",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    default:
      if (bulletCount <= 3) {
        return {
          status: "covered",
          strategy: "static_full",
          overlay_count: 0,
          items: [],
          rationale: "要点少（≤3条），单页完整展示即可",
          source: "deck_strategy_placeholder_repair (auto)",
        };
      }
      return {
        status: "covered",
        strategy: "sequential_reveal",
        overlay_count: bulletCount,
        items: (slide?.bullets || []).slice(0, 6).map((_, i) => ({
          step: i + 1,
          action: "reveal",
          target: `bullet_${i + 1}`,
        })),
        rationale: "要点较多，逐条展开以控制信息密度",
        source: "deck_strategy_placeholder_repair (auto)",
      };
  }
}

function generateDefaultAudienceExplanationStrategy(slide, classification, slides) {
  const { pageType, hasDenseFormula, hasTheorem, hasNumerical, hasFigure, isAppendix } = classification;

  switch (pageType) {
    case "motivation":
      return {
        status: "covered",
        style: "question_driven",
        hook: "先抛出研究问题，再展开背景→方法→结论",
        technical_depth: "moderate",
        key_takeaway: "该页说明研究动机和已有文献缺口，听众应形成对研究方向的整体认知",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "theorem":
      return {
        status: "covered",
        style: "result_first",
        hook: "先给出定理结论，再简要说明推导思路",
        technical_depth: "deep",
        key_takeaway: "定理本身为核心结果，推导细节可在讲解中跳过后在问答环节展开",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "formula_dense":
      return {
        status: "covered",
        style: "step_by_step",
        hook: "逐项解释每个符号和结构的含义，避免听众迷失在符号堆里",
        technical_depth: "deep",
        key_takeaway: "每块公式应有至少一句中文自然语言解释，与symbol对照",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "numerical":
    case "numerical_figure":
      return {
        status: "covered",
        style: "data_storytelling",
        hook: "先说明实验设置→展示结果→对比分析→结论",
        technical_depth: "moderate",
        key_takeaway: "数值结果是理论的有力佐证，听众应能自行对照表和图得出结论",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    case "appendix":
      return {
        status: "covered",
        style: "reference_only",
        hook: "附录为补充内容，讲解时提及即可，不展开详细展示",
        technical_depth: "deep",
        key_takeaway: "附录为技术细节补充，不占用主报告时间",
        source: "deck_strategy_placeholder_repair (auto)",
      };

    default:
      return {
        status: "covered",
        style: "progressive_explanation",
        hook: "先概述该页核心信息，再逐条解释",
        technical_depth: "moderate",
        key_takeaway: "听众应能从该页中提取关键结论",
        source: "deck_strategy_placeholder_repair (auto)",
      };
  }
}

function repairStrategyPlaceholders(slidesPath, options = {}) {
  const dryRun = options.dryRun !== false;
  const report = {
    summary: {
      slides_path: slidesPath,
      dry_run: dryRun,
      overlay_fixed: 0,
      audience_fixed: 0,
      total_fixed: 0,
      slides_json_modified: false,
      details: [],
    },
  };

  if (!fs.existsSync(slidesPath)) {
    report.summary.error = "slides.json not found";
    return report;
  }

  let doc;
  try {
    doc = JSON.parse(fs.readFileSync(slidesPath, "utf8"));
  } catch (err) {
    report.summary.error = `parse_error: ${err.message}`;
    return report;
  }

  const slides = Array.isArray(doc.slides) ? doc.slides : [];
  if (slides.length === 0) {
    report.summary.error = "no slides in slides.json";
    return report;
  }

  for (const slide of slides) {
    if (!isPlainObject(slide)) continue;
    const slideId = String(slide.slide_id || "");

    // Fix overlay_strategy
    if (hasUnresolvedStatus(slide.overlay_strategy)) {
      const classification = classifySlide(slide, slides);
      const strategy = generateDefaultOverlayStrategy(slide, classification, slides);
      if (!dryRun) {
        slide.overlay_strategy = strategy;
      }
      report.summary.overlay_fixed++;
      report.summary.details.push({
        slide_id: slideId,
        field: "overlay_strategy",
        old_status: slide.overlay_strategy?.status || slide.overlay_strategy || "planned",
        new_status: strategy.status,
        page_type: classification.pageType,
      });
    }

    // Fix audience_explanation_strategy
    if (hasUnresolvedStatus(slide.audience_explanation_strategy)) {
      const classification = classifySlide(slide, slides);
      const strategy = generateDefaultAudienceExplanationStrategy(slide, classification, slides);
      if (!dryRun) {
        slide.audience_explanation_strategy = strategy;
      }
      report.summary.audience_fixed++;
      report.summary.details.push({
        slide_id: slideId,
        field: "audience_explanation_strategy",
        old_status: slide.audience_explanation_strategy?.status || slide.audience_explanation_strategy || "planned",
        new_status: strategy.status,
        page_type: classification.pageType,
      });
    }
  }

  report.summary.total_fixed = report.summary.overlay_fixed + report.summary.audience_fixed;

  if (!dryRun && report.summary.total_fixed > 0) {
    doc.strategy_placeholder_last_fixed_by = "deck_strategy_placeholder_repair";
    doc.strategy_placeholder_last_fixed_at = new Date().toISOString();
    fs.writeFileSync(slidesPath, JSON.stringify(doc, null, 2), "utf8");
    report.summary.slides_json_modified = true;
  }

  return report;
}

// ─── CLI ────────────────────────────────────────────────────────
function main() {
  const args = process.argv.slice(2);
  let dryRun = false;
  let targetPath = "";

  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--dry-run") dryRun = true;
    else if (!args[i].startsWith("--")) targetPath = args[i];
  }

  if (!targetPath) {
    console.error("Usage: node deck_strategy_placeholder_repair.js <slides.json> [--dry-run]");
    process.exit(1);
  }

  if (!fs.existsSync(targetPath)) {
    console.error("File not found:", targetPath);
    process.exit(1);
  }

  const report = repairStrategyPlaceholders(targetPath, { dryRun });
  console.log(JSON.stringify(report, null, 2));
}

if (require.main === module) {
  main();
}

module.exports = { repairStrategyPlaceholders, classifySlide, UNRESOLVED_PATTERN };


path: scripts/deck_symbol_canonicalization.js
content: "use strict";

function uniqueStrings(values) {
  return [...new Set((Array.isArray(values) ? values : []).filter(Boolean))];
}

function normalizeSimpleSubscriptNotation(symbol) {
  return String(symbol || "").replace(
    /^((?:\\[A-Za-z]+)|[A-Za-z]|[ΔΩωγθαβφνδμξεϵ])_([A-Za-z0-9]+)$/u,
    "$1_{$2}",
  );
}

function normalizeLooseSubscriptBraces(symbol) {
  return String(symbol || "")
    .replace(/^((?:\\[A-Za-z]+)|[A-Za-z]+|[ΔΩωγθαβφνδμξεϵ])_([A-Za-z0-9]+)\}+$/u, "$1_{$2}")
    .replace(/^((?:\\[A-Za-z]+)|[A-Za-z]+|[ΔΩωγθαβφνδμξεϵ])_\{([^{}]+)\}\}+$/u, "$1_{$2}");
}

const STYLED_LATIN_LETTER_MAP = new Map([
  ...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"].map((letter, index) => [String.fromCodePoint(0x1d4d0 + index), letter]),
  ...[..."abcdefghijklmnopqrstuvwxyz"].map((letter, index) => [String.fromCodePoint(0x1d4ea + index), letter]),
  ...[
    ["𝒜", "A"], ["ℬ", "B"], ["𝒞", "C"], ["𝒟", "D"], ["ℰ", "E"], ["ℱ", "F"], ["𝒢", "G"],
    ["ℋ", "H"], ["ℐ", "I"], ["𝒥", "J"], ["𝒦", "K"], ["ℒ", "L"], ["ℳ", "M"], ["𝒩", "N"],
    ["𝒪", "O"], ["𝒫", "P"], ["𝒬", "Q"], ["ℛ", "R"], ["𝒮", "S"], ["𝒯", "T"], ["𝒰", "U"],
    ["𝒱", "V"], ["𝒲", "W"], ["𝒳", "X"], ["𝒴", "Y"], ["𝒵", "Z"],
    ["𝒶", "a"], ["𝒷", "b"], ["𝒸", "c"], ["𝒹", "d"], ["ℯ", "e"], ["𝒻", "f"], ["ℊ", "g"],
    ["𝒽", "h"], ["𝒾", "i"], ["𝒿", "j"], ["𝓀", "k"], ["𝓁", "l"], ["𝓂", "m"], ["𝓃", "n"],
    ["ℴ", "o"], ["𝓅", "p"], ["𝓆", "q"], ["𝓇", "r"], ["𝓈", "s"], ["𝓉", "t"], ["𝓊", "u"],
    ["𝓋", "v"], ["𝓌", "w"], ["𝓍", "x"], ["𝓎", "y"], ["𝓏", "z"],
  ],
]);

function normalizeStyledLatinLetters(text) {
  return [...String(text || "")].map((char) => STYLED_LATIN_LETTER_MAP.get(char) || char).join("");
}

function stripTexStyleWrappers(text) {
  let normalized = String(text || "");
  let previous = "";
  while (normalized !== previous) {
    previous = normalized;
    normalized = normalized
      .replace(/\\(?:mathrm|mathit|mathbf|mathsf|mathtt|operatorname|texttt|textrm|boldsymbol|bm|mathcal|mathscr|mathbb|mathfrak)\{([^{}]*)\}/g, "$1")
      .replace(/\\(?:mathrm|mathit|mathbf|mathsf|mathtt|operatorname|texttt|textrm|boldsymbol|bm|mathcal|mathscr|mathbb|mathfrak)\s*(\\[A-Za-z]+(?:_\{[^}]+\}|_[A-Za-z0-9]+)?|[A-Za-z])/g, "$1")
      .replace(/\\text\{([^{}]*)\}/g, "$1");
  }
  return normalizeStyledLatinLetters(normalized);
}

function normalizeNotationSymbolText(symbol) {
  let normalized = String(symbol || "").trim();
  if (!normalized) return "";
  if (
    (normalized.startsWith("`") && normalized.endsWith("`"))
    || (normalized.startsWith('"') && normalized.endsWith('"'))
    || (normalized.startsWith("'") && normalized.endsWith("'"))
  ) {
    normalized = normalized.slice(1, -1).trim();
  }
  let changed = true;
  while (changed) {
    changed = false;
    if (/^\$([\s\S]+)\$$/.test(normalized)) {
      normalized = normalized.replace(/^\$([\s\S]+)\$$/, "$1").trim();
      changed = true;
    }
    if (/^\\\(([\s\S]+)\\\)$/.test(normalized)) {
      normalized = normalized.replace(/^\\\(([\s\S]+)\\\)$/, "$1").trim();
      changed = true;
    }
    if (/^\\\[([\s\S]+)\\\]$/.test(normalized)) {
      normalized = normalized.replace(/^\\\[([\s\S]+)\\\]$/, "$1").trim();
      changed = true;
    }
    const strippedFontWrappers = stripTexStyleWrappers(normalized);
    if (strippedFontWrappers !== normalized) {
      normalized = strippedFontWrappers.trim();
      changed = true;
    }
  }
  return normalized
    .replace(/\\_/g, "_")
    .replace(/\\\{/g, "{")
    .replace(/\\\}/g, "}")
    .trim();
}

const STRUCTURED_ARTIFACT_FIELD_SYMBOLS = new Set([
  "artifact_paths",
  "body_appendix_split",
  "conclusion_preview_page",
  "equation_blocks",
  "equation_coverage",
  "figure_coverage",
  "formal_statement_inventory",
  "notation_coverage",
  "paragraph_ledger",
  "roadmap_page",
  "speaker_notes",
  "table_coverage",
  "timing_plan",
  "visible_equation_blocks",
]);

function isStructuredArtifactFieldSymbol(symbol) {
  const normalized = String(symbol || "")
    .trim()
    .replace(/\\_/g, "_")
    .replace(/[^A-Za-z0-9_]+/g, "")
    .toLowerCase();
  return STRUCTURED_ARTIFACT_FIELD_SYMBOLS.has(normalized);
}

function canonicalizeSymbolToken(symbol) {
  const greekMap = new Map([
    ["∂Ω", "\\partial\\Omega"],
    ["Δ", "\\Delta"],
    ["Ω", "\\Omega"],
    ["ω", "\\omega"],
    ["γ", "\\gamma"],
    ["θ", "\\theta"],
    ["α", "\\alpha"],
    ["β", "\\beta"],
    ["φ", "\\phi"],
    ["ν", "\\nu"],
    ["δ", "\\delta"],
    ["μ", "\\mu"],
    ["ξ", "\\xi"],
    ["ε", "\\varepsilon"],
    ["ϵ", "\\varepsilon"],
  ]);
  const combiningAccentMap = new Map([
    ["\u0300", "grave"],
    ["\u0301", "acute"],
    ["\u0302", "hat"],
    ["\u0303", "tilde"],
    ["\u0304", "bar"],
    ["\u0306", "breve"],
    ["\u0307", "dot"],
    ["\u0308", "ddot"],
    ["\u030c", "check"],
    ["\u20d7", "vec"],
  ]);
  let normalized = normalizeStyledLatinLetters(String(symbol || ""))
    .replace(/ℝ/g, "R")
    .replace(/∈/g, "\\in")
    .replace(/×/g, "\\times")
    .replace(/\s+/g, "")
    .trim();
  if (!normalized) return "";
  normalized = normalizeLooseSubscriptBraces(normalized);
  const decoratedMatch = normalized.normalize("NFD").match(/^(.+?)([\u0300-\u036f\u20d7]+)$/u);
  if (decoratedMatch) {
    let [, baseToken, combiningMarks] = decoratedMatch;
    baseToken = normalizeSimpleSubscriptNotation(baseToken);
    for (const [source, replacement] of greekMap.entries()) {
      baseToken = baseToken.split(source).join(replacement);
    }
    if (baseToken) {
      let accented = baseToken;
      let supported = true;
      for (const mark of [...combiningMarks]) {
        const accentMacro = combiningAccentMap.get(mark);
        if (!accentMacro) {
          supported = false;
          break;
        }
        const simpleAccentTarget = /^\\[A-Za-z]+(?:_\{[A-Za-z0-9]+\})?$/.test(accented);
        accented = simpleAccentTarget ? `\\${accentMacro}${accented}` : `\\${accentMacro}{${accented}}`;
      }
      if (supported) {
        normalized = accented;
      }
    }
  }
  normalized = normalizeSimpleSubscriptNotation(normalized);
  for (const [source, replacement] of greekMap.entries()) {
    normalized = normalized.split(source).join(replacement);
  }
  if (normalized.startsWith("\\epsilon")) {
    normalized = `\\varepsilon${normalized.slice("\\epsilon".length)}`;
  }
  if (/^g\([A-Za-z]\)$/.test(normalized)) {
    return normalized;
  }
  return normalized;
}

function splitNotationSymbolPieces(symbol) {
  const raw = normalizeNotationSymbolText(symbol);
  if (!raw) return [];
  const pieces = [];
  let current = "";
  let depth = 0;
  for (const char of raw) {
    if (char === "(" || char === "[" || char === "{") {
      depth += 1;
      current += char;
      continue;
    }
    if (char === ")" || char === "]" || char === "}") {
      depth = Math.max(0, depth - 1);
      current += char;
      continue;
    }
    if (depth === 0 && (char === "/" || char === "," || char === ";")) {
      const piece = current.trim();
      if (piece) pieces.push(piece);
      current = "";
      continue;
    }
    current += char;
  }
  const tail = current.trim();
  if (tail) pieces.push(tail);
  return pieces;
}

function symbolCandidatesFromNotationEntry(symbol) {
  const raw = normalizeNotationSymbolText(symbol);
  if (!raw || /UNKNOWN_RECOVERED_SYMBOL/i.test(raw)) return [];
  if (isStructuredArtifactFieldSymbol(raw)) return [];
  const pieces = splitNotationSymbolPieces(raw);
  const expanded = [];
  const greekWithIndexMap = {
    "Δ": "\\Delta",
    "Ω": "\\Omega",
    "ω": "\\omega",
    "γ": "\\gamma",
    "θ": "\\theta",
    "α": "\\alpha",
    "β": "\\beta",
    "φ": "\\phi",
    "ν": "\\nu",
    "δ": "\\delta",
    "μ": "\\mu",
    "ξ": "\\xi",
    "ε": "\\varepsilon",
    "ϵ": "\\varepsilon",
  };
  for (const piece of pieces) {
    expanded.push(piece);
    if (/^\\[A-Za-z]+$/.test(piece)) {
      expanded.push(piece.replace(/^\\/, ""));
    }
    const macroWithArgs = piece.match(/^(\\[A-Za-z]+)(\(.+\))$/);
    if (macroWithArgs) {
      expanded.push(macroWithArgs[1]);
    }
    const indexedSymbol = piece.match(/^((?:\\[A-Za-z]+)|[A-Za-z]|[ΔΩωγθαβφνδμξεϵ])_(\{[^}]+\}|[A-Za-z0-9]+)(\(.+\))?$/u);
    if (indexedSymbol) {
      const [, baseSymbol, , suffix = ""] = indexedSymbol;
      expanded.push(baseSymbol);
      if (suffix) {
        expanded.push(`${baseSymbol}${suffix}`);
      }
    }
    if (/^g\([A-Za-z]\)$/.test(piece)) {
      expanded.push(piece.replace(/\([A-Za-z]\)$/, ""));
    }
    const greekNameWithIndex = piece.match(/^(Delta|Omega|omega|gamma|theta|alpha|beta|phi|nu|delta|mu|xi|epsilon|varepsilon)_(\{[^}]+\}|[A-Za-z0-9]+)$/);
    if (greekNameWithIndex) {
      const [, greekName, index] = greekNameWithIndex;
      const macroName = greekName === "epsilon" ? "varepsilon" : greekName;
      expanded.push(`\\${macroName}_${index}`);
      if (!/^\{[^}]+\}$/.test(index)) expanded.push(`\\${macroName}_{${index}}`);
    }
    const greekWithIndex = piece.match(/^([ΔΩωγθαβφνδμξεϵ])(\d+)$/u);
    if (greekWithIndex) {
      const [, greek, index] = greekWithIndex;
      expanded.push(`${greek}_${index}`);
      expanded.push(`${greek}_{${index}}`);
      if (greekWithIndexMap[greek]) {
        expanded.push(`${greekWithIndexMap[greek]}_${index}`);
        expanded.push(`${greekWithIndexMap[greek]}_{${index}}`);
      }
    }
    const indexedFunction = piece.match(/^([A-Za-z])(\d+)(\(.+\))$/);
    if (indexedFunction) {
      const [, base, index, suffix] = indexedFunction;
      expanded.push(`${base}_${index}${suffix}`);
      expanded.push(`${base}_{${index}}${suffix}`);
    }
  }
  return uniqueStrings(expanded.map(canonicalizeSymbolToken));
}

function escapeNonSubscriptUnderscores(text) {
  let normalized = String(text || "");
  let previous = "";
  while (normalized !== previous) {
    previous = normalized;
    normalized = normalized.replace(/([A-Za-z0-9])_([A-Za-z0-9])/g, "$1\\\\_$2");
  }
  return normalized;
}

function normalizeTexSymbolHaystack(text) {
  const normalized = stripTexStyleWrappers(text);
  return normalized
    .replace(/\\_/g, "_")
    .replace(/\\\{/g, "{")
    .replace(/\\\}/g, "}")
    .replace(/\\([,:;!])/g, "$1");
}

function textContainsSymbolCandidate(candidate, text) {
  const haystack = String(text || "");
  const unescapedHaystack = haystack.includes("\\\\") ? haystack.replace(/\\\\/g, "\\") : haystack;
  const normalizedHaystack = normalizeTexSymbolHaystack(haystack);
  const normalizedUnescapedHaystack = normalizeTexSymbolHaystack(unescapedHaystack);
  const needle = canonicalizeSymbolToken(normalizeNotationSymbolText(candidate));
  if (!needle || !haystack) return false;
  const variants = new Set([needle]);
  const unicodeMap = {
    "Δ": "\\Delta",
    "\\Delta": "Δ",
    "\\Omega": "Ω",
    "\\omega": "ω",
    "\\gamma": "γ",
    "\\theta": "θ",
    "\\alpha": "α",
    "\\beta": "β",
    "\\phi": "φ",
    "\\nu": "ν",
    "\\delta": "δ",
    "\\mu": "μ",
    "\\xi": "ξ",
    "\\epsilon": "ε",
    "\\varepsilon": "ε",
    "\\partial\\Omega": "∂Ω",
  };
  if (unicodeMap[needle]) variants.add(unicodeMap[needle]);
  if (needle.startsWith("\\")) variants.add(needle.replace(/^\\/, ""));
  if (!needle.startsWith("\\") && /^[A-Za-z]+(?:_\{?[A-Za-z0-9]+\}?)?$/.test(needle)) variants.add(`\\${needle}`);
  if (needle.includes("_{")) variants.add(needle.replace(/_\{([^}]+)\}/g, "_$1"));
  if (/_[A-Za-z0-9]+/.test(needle)) variants.add(needle.replace(/_([A-Za-z0-9]+)/g, "_{$1}"));
  if (needle.includes("_")) variants.add(escapeNonSubscriptUnderscores(needle));
  if (/^g\([A-Za-z]\)$/.test(needle)) variants.add(needle.replace(/\([A-Za-z]\)$/, ""));
  const accentNoBraceMatch = needle.match(/^\\(tilde|hat|bar|breve|acute|grave|dot|ddot|check|vec)(\\[A-Za-z]+(?:_\{[A-Za-z0-9]+\})?)$/);
  if (accentNoBraceMatch) variants.add(`\\${accentNoBraceMatch[1]}{${accentNoBraceMatch[2]}}`);
  const accentBracedMatch = needle.match(/^\\(tilde|hat|bar|breve|acute|grave|dot|ddot|check|vec)\{(.+)\}$/);
  if (accentBracedMatch) variants.add(`\\${accentBracedMatch[1]}${accentBracedMatch[2]}`);
  // Handle accent braces embedded inside a larger symbol, e.g.
  // \\hat{r}_t vs \\hat r_t, and W_t^{\\hat{r}} vs W_t^{\\hat r}.
  for (const variant of [...variants]) {
    if (/\\(?:tilde|hat|bar|breve|acute|grave|dot|ddot|check|vec)\{[^{}]+\}/.test(variant)) {
      variants.add(variant.replace(/\\(tilde|hat|bar|breve|acute|grave|dot|ddot|check|vec)\{([^{}]+)\}/g, "\\$1$2"));
      variants.add(variant.replace(/\\(tilde|hat|bar|breve|acute|grave|dot|ddot|check|vec)\{([^{}]+)\}/g, "\\$1 $2"));
    }
  }
  if (needle.includes(";")) variants.add(needle.replace(/;/g, "\\;"));
  for (const variant of [...variants]) {
    if (variant.includes("_")) {
      variants.add(escapeNonSubscriptUnderscores(variant));
    }
  }
  for (const variant of [...variants]) {
    if (variant.startsWith("\\varepsilon")) {
      variants.add(`\\epsilon${variant.slice("\\varepsilon".length)}`);
    }
    if (variant.startsWith("\\epsilon")) {
      variants.add(`\\varepsilon${variant.slice("\\epsilon".length)}`);
    }
  }
  const haystacks = [
    haystack,
    haystack.replace(/\s+/g, ""),
    unescapedHaystack,
    unescapedHaystack.replace(/\s+/g, ""),
    normalizedHaystack,
    normalizedHaystack.replace(/\s+/g, ""),
    normalizedUnescapedHaystack,
    normalizedUnescapedHaystack.replace(/\s+/g, ""),
  ];
  return [...variants].some((variant) => {
    if (!variant) return false;
    const compactVariant = String(variant).replace(/\s+/g, "");
    return haystacks.some((entry) => entry.includes(variant) || entry.includes(compactVariant));
  });
}

function isGenericDifferentialToken(token) {
  const value = String(token || "").trim();
  // Differential terms such as dv_t, dr_t, and dW_t are operators attached to
  // state/Brownian variables in SDEs, not standalone notation entries that
  // should be forced into notation_coverage.
  return /^d[A-Za-z]+_\{?[A-Za-z0-9]+\}?$/i.test(value);
}

function isLikelyDerivativeSubscriptToken(token) {
  const value = String(token || "").trim();
  // Terms such as f_{vv}, f_{rr}, f_{\zeta\zeta} denote partial derivatives
  // of a covered function, not independent notation items.
  return /^[A-Za-z]_\{?(?:[A-Za-z]{2,}|\\[A-Za-z]+\\[A-Za-z]+)\}?$/.test(value);
}

function extractLikelyMathSymbolsFromEquationText(text) {
  const raw = String(text || "");
  const sanitized = stripTexStyleWrappers(raw)
    .replace(/\\(?:label|ref|eqref|autoref|cref|Cref)\{[^}]*\}/g, " ")
    .replace(/\\includegraphics(?:\[[^\]]*\])?\{[^}]*\}/g, " ");
  const tokens = new Set();
  for (const match of sanitized.matchAll(/\\partial\s*\\Omega/g)) tokens.add("\\partial\\Omega");
  for (const match of sanitized.matchAll(/\\Omega/g)) tokens.add("\\Omega");
  for (const match of sanitized.matchAll(/\\(?:theta|omega|gamma|alpha|beta|phi|nu|delta|mu|xi|varepsilon|epsilon)(?:_\{[A-Za-z0-9]+\}|_[A-Za-z0-9])?/g)) tokens.add(match[0]);
  for (const match of sanitized.matchAll(/\b[a-zA-Z]+_\{[a-zA-Z0-9]+\}/g)) {
    const startIndex = Number(match.index || 0);
    if (startIndex > 0 && sanitized[startIndex - 1] === "\\") continue;
    tokens.add(match[0]);
  }
  for (const match of sanitized.matchAll(/\b[a-zA-Z]+_[a-zA-Z0-9]\b/g)) {
    const startIndex = Number(match.index || 0);
    if (startIndex > 0 && sanitized[startIndex - 1] === "\\") continue;
    tokens.add(match[0]);
  }
  for (const match of sanitized.matchAll(/\bg\([A-Za-z]\)/g)) tokens.add(match[0]);
  const ignore = new Set([
    "\\alpha",
    "\\phi",
    "\\nu",
    "equation_blocks",
    "r_l",
    "z_l",
    "x_l",
    "\\theta_l",
  ]);
  return uniqueStrings([...tokens]
    .map(canonicalizeSymbolToken)
    .filter((token) => token
      && !ignore.has(token)
      && !isStructuredArtifactFieldSymbol(token)
      && !isGenericDifferentialToken(token)
      && !isLikelyDerivativeSubscriptToken(token)));
}

module.exports = {
  canonicalizeSymbolToken,
  escapeNonSubscriptUnderscores,
  extractLikelyMathSymbolsFromEquationText,
  isStructuredArtifactFieldSymbol,
  normalizeLooseSubscriptBraces,
  normalizeNotationSymbolText,
  normalizeSimpleSubscriptNotation,
  normalizeTexSymbolHaystack,
  splitNotationSymbolPieces,
  stripTexStyleWrappers,
  symbolCandidatesFromNotationEntry,
  textContainsSymbolCandidate,
};


path: scripts/prepare_task_assets.js
content: #!/usr/bin/env node

const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");

const IMAGE_CONTENT_TYPE_EXT = {
  "image/png": ".png",
  "image/jpeg": ".jpg",
  "image/jpg": ".jpg",
  "image/webp": ".webp",
  "image/gif": ".gif",
  "image/bmp": ".bmp",
  "image/svg+xml": ".svg",
};

const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"]);

function fail(message) {
  process.stderr.write(`${message}\n`);
  process.exit(1);
}

function ensureDir(dirPath) {
  fs.mkdirSync(dirPath, { recursive: true });
}

function sha256(input) {
  return crypto.createHash("sha256").update(input).digest("hex");
}

function readText(filePath) {
  return fs.readFileSync(filePath, "utf8");
}

function parseArgs(argv) {
  const args = [...argv];
  let mode = "generic";
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === "--mode") {
      mode = args[i + 1] || "generic";
      args.splice(i, 2);
      i -= 2;
    }
  }
  if (args.length !== 2) {
    fail("usage: prepare_task_assets.js <source-file> <task-dir> [--mode <ppt|beamer|generic>]");
  }
  return {
    sourceFile: path.resolve(args[0]),
    taskDir: path.resolve(args[1]),
    mode,
  };
}

function extractMarkdownImages(text) {
  const matches = [];
  const regex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)(?:\s+"[^"]*")?\)/g;
  let match;
  let order = 0;
  while ((match = regex.exec(text)) !== null) {
    order += 1;
    matches.push({
      order,
      alt: String(match[1] || "").trim(),
      url: String(match[2] || "").trim(),
      source_excerpt: String(match[0] || "").slice(0, 240),
    });
  }
  return matches;
}

function looksDecorative(asset) {
  const sample = `${asset.alt || ""} ${asset.url || ""}`.toLowerCase();
  return /(author|avatar|logo|icon|badge|emoji|favicon|profile|headshot)/i.test(sample);
}

function inferExt(url, contentType) {
  const cleanType = String(contentType || "").split(";")[0].trim().toLowerCase();
  if (IMAGE_CONTENT_TYPE_EXT[cleanType]) {
    return IMAGE_CONTENT_TYPE_EXT[cleanType];
  }
  try {
    const pathname = new URL(url).pathname;
    const ext = path.extname(pathname).toLowerCase();
    if (IMAGE_EXTS.has(ext)) {
      return ext === ".jpeg" ? ".jpg" : ext;
    }
  } catch {
    // ignore
  }
  return ".bin";
}

async function fetchBuffer(url) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 60000);
  try {
    const response = await fetch(url, {
      redirect: "follow",
      signal: controller.signal,
      headers: {
        "user-agent": "openclaw-pipeline/prepare-task-assets",
      },
    });
    if (!response.ok) {
      throw new Error(`http ${response.status}`);
    }
    const arrayBuffer = await response.arrayBuffer();
    return {
      buffer: Buffer.from(arrayBuffer),
      contentType: response.headers.get("content-type") || "",
      finalUrl: response.url || url,
    };
  } finally {
    clearTimeout(timer);
  }
}

function writeJson(filePath, payload) {
  ensureDir(path.dirname(filePath));
  fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}

function copyFile(src, dest) {
  ensureDir(path.dirname(dest));
  fs.copyFileSync(src, dest);
}

async function main() {
  const { sourceFile, taskDir, mode } = parseArgs(process.argv.slice(2));
  if (!fs.existsSync(sourceFile)) {
    fail(`source file not found: ${sourceFile}`);
  }
  ensureDir(taskDir);

  const text = readText(sourceFile);
  const discovered = extractMarkdownImages(text);
  const taskScope = sha256(taskDir).slice(0, 16);
  const figuresDir = path.join(taskDir, "figures");
  const cacheDir = path.join(taskDir, "_asset_cache", taskScope);
  const manifestPath = path.join(taskDir, "asset_manifest.json");
  ensureDir(figuresDir);
  ensureDir(cacheDir);

  const seenUrls = new Map();
  const items = [];

  for (const asset of discovered) {
    const urlHash = sha256(asset.url);
    const entry = {
      index: asset.order,
      mode,
      task_scope: taskScope,
      alt: asset.alt,
      url: asset.url,
      url_hash: urlHash,
      source_excerpt: asset.source_excerpt,
      status: "pending",
      cache: {
        task_scope: taskScope,
        url_hash: urlHash,
        file_sha256: null,
      },
      local_path: null,
      cache_path: null,
      duplicate_of: null,
      error: null,
    };

    if (looksDecorative(asset)) {
      entry.status = "skipped";
      entry.error = "decorative_inline_image";
      items.push(entry);
      continue;
    }

    if (seenUrls.has(asset.url)) {
      const original = seenUrls.get(asset.url);
      entry.status = "duplicate";
      entry.duplicate_of = original.index;
      entry.local_path = original.local_path;
      entry.cache_path = original.cache_path;
      entry.cache.file_sha256 = original.cache.file_sha256;
      items.push(entry);
      continue;
    }

    const urlMetaPath = path.join(cacheDir, `${urlHash}.json`);
    try {
      let cachedMeta = null;
      if (fs.existsSync(urlMetaPath)) {
        try {
          cachedMeta = JSON.parse(readText(urlMetaPath));
        } catch {
          cachedMeta = null;
        }
      }

      let cachePath = cachedMeta?.cache_path || null;
      let fileSha = cachedMeta?.file_sha256 || null;
      let finalUrl = cachedMeta?.final_url || asset.url;
      let ext = cachedMeta?.ext || null;

      if (!cachePath || !fs.existsSync(cachePath)) {
        const fetched = await fetchBuffer(asset.url);
        finalUrl = fetched.finalUrl;
        fileSha = sha256(fetched.buffer);
        ext = inferExt(finalUrl, fetched.contentType);
        cachePath = path.join(cacheDir, `${urlHash}_${fileSha.slice(0, 12)}${ext}`);
        fs.writeFileSync(cachePath, fetched.buffer);
        writeJson(urlMetaPath, {
          url: asset.url,
          final_url: finalUrl,
          url_hash: urlHash,
          file_sha256: fileSha,
          ext,
          cache_path: cachePath,
          task_scope: taskScope,
        });
      }

      const localPath = path.join(figuresDir, `fig${String(asset.order).padStart(2, "0")}${ext || path.extname(cachePath) || ".bin"}`);
      copyFile(cachePath, localPath);
      entry.status = "success";
      entry.local_path = localPath;
      entry.cache_path = cachePath;
      entry.cache.file_sha256 = fileSha;
      entry.final_url = finalUrl;
      seenUrls.set(asset.url, entry);
    } catch (error) {
      entry.status = "failed";
      entry.error = error instanceof Error ? error.message : String(error);
    }

    items.push(entry);
  }

  const summary = {
    source_file: sourceFile,
    task_dir: taskDir,
    mode,
    task_scope: taskScope,
    generated_at: new Date().toISOString(),
    counts: {
      discovered: discovered.length,
      success: items.filter((item) => item.status === "success").length,
      failed: items.filter((item) => item.status === "failed").length,
      skipped: items.filter((item) => item.status === "skipped").length,
      duplicate: items.filter((item) => item.status === "duplicate").length,
    },
    items,
  };

  writeJson(manifestPath, summary);

  process.stdout.write([
    `asset_manifest: ${manifestPath}`,
    `task_scope: ${taskScope}`,
    `discovered: ${summary.counts.discovered}`,
    `success: ${summary.counts.success}`,
    `failed: ${summary.counts.failed}`,
    `skipped: ${summary.counts.skipped}`,
    `duplicate: ${summary.counts.duplicate}`,
  ].join("\n") + "\n");
}

main().catch((error) => fail(error instanceof Error ? error.stack || error.message : String(error)));


path: scripts/run_beamer_public.js
content: #!/usr/bin/env node
"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const {
  DECK_PIPELINE_PHASE_BLUEPRINTS,
  DECK_PIPELINE_TOTAL_PHASES,
  getBeamerPipelinePhase,
  phaseAllowsPlannedCoverageField,
  requiredArtifactNamesForPhase,
  summarizePhase,
} = require("./beamer_phase_blueprint");

const DEFAULT_AGENT_CMD = "openclaw agent --agent {role} --message \"$(cat {prompt})\"";

function fail(message) {
  process.stderr.write(`${message}\n`);
  process.exit(1);
}

function parseArgs(argv) {
  const options = {
    input: "",
    out: "",
    skipAssets: false,
    dryRun: false,
    initOnly: false,
    noAgent: false,
    phase: 0,
    validatePhase: 0,
    agentCmd: "",
    latexCmd: process.env.LATEXMK_BIN || "latexmk",
  };
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    const readNext = () => {
      const value = argv[i + 1];
      if (!value || String(value).startsWith("--")) fail(`missing value for ${arg}`);
      i += 1;
      return value;
    };
    if (arg === "--input") {
      options.input = readNext();
    } else if (arg === "--out") {
      options.out = readNext();
    } else if (arg === "--skip-assets") {
      options.skipAssets = true;
    } else if (arg === "--dry-run") {
      options.dryRun = true;
    } else if (arg === "--init-only") {
      options.initOnly = true;
    } else if (arg === "--no-agent") {
      options.noAgent = true;
    } else if (arg === "--phase") {
      options.phase = Number(readNext());
    } else if (arg === "--validate-phase") {
      options.validatePhase = Number(readNext());
    } else if (arg === "--agent-cmd") {
      options.agentCmd = readNext();
    } else if (arg === "--latex-cmd") {
      options.latexCmd = readNext();
    } else if (arg === "--help" || arg === "-h") {
      printHelp();
      process.exit(0);
    } else {
      fail(`unknown argument: ${arg}`);
    }
  }
  if (!options.input || !options.out) {
    printHelp();
    process.exit(1);
  }
  return {
    ...options,
    input: path.resolve(options.input),
    out: path.resolve(options.out),
  };
}

function printHelp() {
  process.stdout.write([
    "usage: node scripts/run_beamer_public.js --input <paper.md> --out <output-dir> [options]",
    "",
    "Options:",
    "  --init-only              prepare workspace/contract/assets only",
    "  --dry-run                print the 7-phase plan and required artifacts without invoking an agent",
    "  --no-agent               write prompts only, skip all agent invocations (for inspection)",
    "  --phase <1-7>            run exactly one phase",
    "  --validate-phase <2-6>   validate existing artifacts for one phase gate",
    "  --agent-cmd <command>    command used to produce/repair artifacts for each phase (default: openclaw agent --agent {role} --message \"$(cat {prompt})\")",
    "                           placeholders: {prompt} {phase} {phaseName} {role} {out} {input}",
    "  --latex-cmd <command>    LaTeX command for phase 5, default: LATEXMK_BIN or latexmk",
    "  --skip-assets            skip prepare_task_assets.js",
    "",
    "Examples:",
    "  node scripts/run_beamer_public.js --input paper.md --out out --dry-run",
    "  node scripts/run_beamer_public.js --input paper.md --out out --no-agent",
    "  node scripts/run_beamer_public.js --input paper.md --out out",
    "  node scripts/run_beamer_public.js --input paper.md --out out --agent-cmd 'openclaw agent --agent {role} --message \"$(cat {prompt})\"'",
    "  node scripts/run_beamer_public.js --input paper.md --out out --validate-phase 5",
  ].join("\n") + "\n");
}

function ensureDir(dir) {
  fs.mkdirSync(dir, { recursive: true });
}

function readText(filePath) {
  return fs.readFileSync(filePath, "utf8");
}

function writeText(filePath, content) {
  ensureDir(path.dirname(filePath));
  fs.writeFileSync(filePath, content, "utf8");
}

function readJson(filePath) {
  return JSON.parse(readText(filePath));
}

function fileExists(filePath) {
  return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}

function dirExists(dirPath) {
  return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
}

function shellQuote(value) {
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

function runPrepareAssets(skillRoot, input, out) {
  const script = path.join(skillRoot, "scripts", "prepare_task_assets.js");
  const result = spawnSync(process.execPath, [script, input, out, "--mode", "beamer"], {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
    maxBuffer: 10 * 1024 * 1024,
  });
  if (result.status !== 0) {
    fail((result.stderr || result.stdout || "prepare_task_assets.js failed").trim());
  }
  return result.stdout.trim();
}

function workspacePaths(out) {
  return {
    analysis: path.join(out, "analysis.json"),
    slides: path.join(out, "slides.json"),
    mainTex: path.join(out, "main.tex"),
    mainPdf: path.join(out, "main.pdf"),
    readme: path.join(out, "README.md"),
    assetManifest: path.join(out, "asset_manifest.json"),
    figures: path.join(out, "figures"),
    contract: path.join(out, "beamer_task_contract.md"),
    state: path.join(out, "beamer_pipeline_state.json"),
    phaseLog: path.join(out, "beamer_phase_log.jsonl"),
  };
}

function artifactPath(out, name) {
  const paths = workspacePaths(out);
  if (name === "analysis.json") return paths.analysis;
  if (name === "slides.json") return paths.slides;
  if (name === "main.tex") return paths.mainTex;
  if (name === "main.pdf") return paths.mainPdf;
  if (name === "README.md") return paths.readme;
  if (name === "asset_manifest.json") return paths.assetManifest;
  if (name === "figures") return paths.figures;
  return path.join(out, name);
}

function artifactExists(out, name) {
  const candidate = artifactPath(out, name);
  return name === "figures" ? dirExists(candidate) : fileExists(candidate);
}

function roleForPhase(phase) {
  if (phase.index <= 5) return "programmer";
  if (phase.index === 6) return "reviewer";
  if (phase.index === 7) return "tester";
  return "programmer";
}

function validateRequiredArtifacts(out, phase) {
  const missing = requiredArtifactNamesForPhase(phase).filter((name) => !artifactExists(out, name));
  return missing;
}

function validateJsonArtifacts(out, phase) {
  const errors = [];
  for (const name of ["analysis.json", "slides.json"]) {
    if (!requiredArtifactNamesForPhase(phase).includes(name) || !artifactExists(out, name)) continue;
    try {
      readJson(artifactPath(out, name));
    } catch (error) {
      errors.push(`${name} is not valid JSON: ${error.message}`);
    }
  }
  return errors;
}

function validateCoverageGates(out, phase) {
  const errors = [];
  const slidesPath = artifactPath(out, "slides.json");
  const analysisPath = artifactPath(out, "analysis.json");
  const readIfPresent = (filePath) => fileExists(filePath) ? readJson(filePath) : {};
  let slides = {};
  let analysis = {};
  try { slides = readIfPresent(slidesPath); } catch { slides = {}; }
  try { analysis = readIfPresent(analysisPath); } catch { analysis = {}; }

  const phaseIndex = Number(phase?.index || 0) || 0;
  const equationCoverage = Array.isArray(slides.equation_coverage) ? slides.equation_coverage
    : (Array.isArray(analysis.equation_coverage) ? analysis.equation_coverage : []);
  const notationCoverage = Array.isArray(slides.notation_coverage) ? slides.notation_coverage
    : (Array.isArray(analysis.notation_coverage) ? analysis.notation_coverage : []);

  if (phaseIndex >= 3 && !phaseAllowsPlannedCoverageField(phase, "equation_coverage")) {
    const unresolved = equationCoverage.filter((entry) => /^(planned|blocked|missing|partial)$/i.test(String(entry?.status || "")) || !Array.isArray(entry?.slide_ids) || entry.slide_ids.length === 0);
    if (unresolved.length > 0) errors.push(`equation_coverage has ${unresolved.length} unresolved entries after phase ${phaseIndex}`);
  }
  if (phaseIndex >= 4 && !phaseAllowsPlannedCoverageField(phase, "notation_coverage")) {
    const unresolved = notationCoverage.filter((entry) => /^(planned|blocked|missing|partial)$/i.test(String(entry?.status || "")) || !Array.isArray(entry?.first_defined_slide_ids) || entry.first_defined_slide_ids.length === 0 || entry.defined_on_first_visible_use !== true);
    if (unresolved.length > 0) errors.push(`notation_coverage has ${unresolved.length} unresolved entries after phase ${phaseIndex}`);
  }
  return errors;
}

function validatePhase(out, phase) {
  const errors = [];
  errors.push(...validateRequiredArtifacts(out, phase).map((name) => `missing required artifact: ${name}`));
  errors.push(...validateJsonArtifacts(out, phase));
  errors.push(...validateCoverageGates(out, phase));
  if (phase.index >= 5 && artifactExists(out, "main.tex") && artifactExists(out, "main.pdf")) {
    const texMtime = fs.statSync(artifactPath(out, "main.tex")).mtimeMs;
    const pdfMtime = fs.statSync(artifactPath(out, "main.pdf")).mtimeMs;
    if (pdfMtime + 1000 < texMtime) errors.push("main.pdf is older than main.tex; rerun LaTeX");
  }
  return errors;
}

function compileLatex(out, latexCmd) {
  if (!artifactExists(out, "main.tex")) return { skipped: true, reason: "main.tex missing" };
  const args = latexCmd === "latexmk"
    ? ["-pdf", "-xelatex", "-interaction=nonstopmode", "-halt-on-error", "main.tex"]
    : ["main.tex"];
  const result = spawnSync(latexCmd, args, {
    cwd: out,
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
    timeout: 120000,
    maxBuffer: 20 * 1024 * 1024,
  });
  return {
    skipped: false,
    status: result.status,
    stdout: result.stdout || "",
    stderr: result.stderr || "",
  };
}

function phasePrompt(input, out, phase) {
  return [
    `You are running the public Beamer seven-phase pipeline.`,
    `Phase: ${summarizePhase(phase)} (${phase.name})`,
    `Goal: ${phase.goal}`,
    `Input source: ${input}`,
    `Output directory: ${out}`,
    `Required artifacts for this phase: ${requiredArtifactNamesForPhase(phase).join(", ")}`,
    `Use beamer_task_contract.md as the contract. Preserve existing artifacts and update only the output directory.`,
    `Return or write artifacts on disk: analysis.json, slides.json, main.tex, main.pdf, README.md, asset_manifest.json, figures as required.`,
  ].join("\n");
}

function runAgentCommand(agentCmd, input, out, phase) {
  const promptPath = path.join(out, `phase_${phase.index}_${phase.name}_prompt.md`);
  writeText(promptPath, phasePrompt(input, out, phase));
  const command = agentCmd
    .replaceAll("{prompt}", shellQuote(promptPath))
    .replaceAll("{phase}", String(phase.index))
    .replaceAll("{phaseName}", phase.name)
    .replaceAll("{role}", roleForPhase(phase))
    .replaceAll("{out}", shellQuote(out))
    .replaceAll("{input}", shellQuote(input));
  const result = spawnSync(command, {
    shell: true,
    cwd: out,
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
    timeout: 0,
    maxBuffer: 50 * 1024 * 1024,
  });
  return {
    command,
    promptPath,
    status: result.status,
    stdout: result.stdout || "",
    stderr: result.stderr || "",
  };
}

function appendPhaseLog(out, entry) {
  writeText(path.join(out, ".keep"), "");
  fs.appendFileSync(workspacePaths(out).phaseLog, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, "utf8");
}

function writeState(out, state) {
  writeText(workspacePaths(out).state, `${JSON.stringify(state, null, 2)}\n`);
}

function initializeWorkspace(skillRoot, options) {
  ensureDir(options.out);
  const contractPath = path.join(skillRoot, "templates", "beamer_contract.md");
  const contract = readText(contractPath);
  const sourceBase = path.basename(options.input);
  const paths = workspacePaths(options.out);

  let assetSummary = "asset preparation skipped by --skip-assets";
  if (!options.skipAssets) {
    assetSummary = runPrepareAssets(skillRoot, options.input, options.out) || "asset preparation completed";
  }

  writeText(paths.contract, `${contract}\n\n## Current task\n\nSource file: ${options.input}\nOutput directory: ${options.out}\nSource basename: ${sourceBase}\n\nUse the source file and generated asset_manifest.json to produce analysis.json, slides.json, main.tex, and main.pdf when feasible.\n`);

  if (!fileExists(paths.readme)) {
    writeText(paths.readme, `# Beamer package workspace\n\nSource file: ${options.input}\n\nOutput directory: ${options.out}\n\nAsset step: ${assetSummary}\n\nPipeline:\n${DECK_PIPELINE_PHASE_BLUEPRINTS.map((phase, index) => `${index + 1}. ${phase.name}: ${phase.goal}`).join("\n")}\n`);
  }

  return { paths, assetSummary };
}

function printDryRun(options) {
  process.stdout.write(`Beamer seven-phase dry run\ninput: ${options.input}\nout: ${options.out}\n\n`);
  for (let index = 1; index <= DECK_PIPELINE_TOTAL_PHASES; index += 1) {
    const phase = getBeamerPipelinePhase(index);
    process.stdout.write(`${summarizePhase(phase)}\n`);
    process.stdout.write(`  name: ${phase.name}\n`);
    process.stdout.write(`  goal: ${phase.goal}\n`);
    process.stdout.write(`  required: ${requiredArtifactNamesForPhase(phase).join(", ")}\n`);
    process.stdout.write(`  reviewerPhase: ${phase.reviewerPhase ? "true" : "false"}\n`);
    process.stdout.write(`  finalPhase: ${phase.finalPhase ? "true" : "false"}\n\n`);
  }
}

function runOnePhase(options, phase) {
  appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "start" });
  if (phase.index === 5) {
    const compileResult = compileLatex(options.out, options.latexCmd);
    appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "latex", result: compileResult.skipped ? compileResult : { status: compileResult.status } });
  }

  const effectiveAgentCmd = options.agentCmd || (!options.noAgent ? DEFAULT_AGENT_CMD : "");
  if (effectiveAgentCmd) {
    const agentResult = runAgentCommand(effectiveAgentCmd, options.input, options.out, phase);
    appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "agent", command: agentResult.command, status: agentResult.status, promptPath: agentResult.promptPath });
    if (agentResult.status !== 0) {
      writeText(path.join(options.out, `phase_${phase.index}_${phase.name}_agent.stderr.log`), agentResult.stderr);
      writeText(path.join(options.out, `phase_${phase.index}_${phase.name}_agent.stdout.log`), agentResult.stdout);
      fail(`agent command failed in ${summarizePhase(phase)}; see phase logs in ${options.out}`);
    }
  } else {
    writeText(path.join(options.out, `phase_${phase.index}_${phase.name}_prompt.md`), phasePrompt(options.input, options.out, phase));
    appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "prompt_written_no_agent" });
  }

  const errors = validatePhase(options.out, phase);
  appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "validate", errors });
  if (errors.length > 0) {
    fail(`${summarizePhase(phase)} validation failed:\n- ${errors.join("\n- ")}`);
  }
  appendPhaseLog(options.out, { phase: phase.index, name: phase.name, event: "done" });
  return { phase: phase.index, name: phase.name, ok: true };
}

function main() {
  const options = parseArgs(process.argv.slice(2));
  if (!fs.existsSync(options.input)) fail(`input file not found: ${options.input}`);
  const skillRoot = path.resolve(__dirname, "..");
  const init = initializeWorkspace(skillRoot, options);

  if (options.dryRun) {
    printDryRun(options);
    return;
  }
  if (options.initOnly) {
    process.stdout.write([
      "Beamer public workspace prepared.",
      `input: ${options.input}`,
      `out: ${options.out}`,
      `contract: ${init.paths.contract}`,
      `readme: ${init.paths.readme}`,
      `assets: ${init.assetSummary}`,
    ].join("\n") + "\n");
    return;
  }
  if (options.validatePhase) {
    const phase = getBeamerPipelinePhase(options.validatePhase);
    if (phase.index < 2 || phase.index > 6) fail("--validate-phase currently supports phases 2-6");
    const errors = validatePhase(options.out, phase);
    if (errors.length > 0) fail(`${summarizePhase(phase)} validation failed:\n- ${errors.join("\n- ")}`);
    process.stdout.write(`${summarizePhase(phase)} validation passed.\n`);
    return;
  }

  const phases = options.phase ? [getBeamerPipelinePhase(options.phase)] : DECK_PIPELINE_PHASE_BLUEPRINTS.map((_, index) => getBeamerPipelinePhase(index + 1));
  const completed = [];
  for (const phase of phases) {
    completed.push(runOnePhase(options, phase));
    writeState(options.out, { input: options.input, out: options.out, completed, total: DECK_PIPELINE_TOTAL_PHASES });
  }
  process.stdout.write(`Beamer seven-phase pipeline completed: ${completed.map((item) => item.name).join(", ")}\n`);
}

main();


path: scripts/slide_schema.js
content: "use strict";

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function isNonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0;
}

function normalizeEquationNumbers(value) {
  const raw = Array.isArray(value) ? value : (value === undefined || value === null ? [] : [value]);
  const seen = new Set();
  const numbers = [];
  for (let item of raw) {
    if (isPlainObject(item)) {
      item = item.number ?? item.label ?? item.id ?? item.text;
    }
    let text = String(item ?? "").trim();
    if (!text) continue;
    text = text.replace(/^Eq(?:uation)?\.?\s*/i, "").trim();
    if (text.startsWith("(") && text.endsWith(")") && text.length >= 2) {
      text = text.slice(1, -1).trim();
    }
    if (!text || seen.has(text)) continue;
    seen.add(text);
    numbers.push(text);
  }
  return numbers;
}

function equationNumbersFromEntry(entry) {
  if (!isPlainObject(entry)) return [];
  for (const key of ["equation_numbers", "equation_number", "numbers", "number", "equation_ids", "equation_refs"]) {
    const numbers = normalizeEquationNumbers(entry[key]);
    if (numbers.length > 0) return numbers;
  }
  return normalizeEquationNumbers(entry.label ?? entry.tag ?? entry.source_label);
}

function isNumberedEquationRef(value) {
  return /^[A-Za-z]?\d+(?:\.\d+)?$/.test(String(value || "").trim());
}

function equationRefLabel(numbers) {
  const cleaned = (Array.isArray(numbers) ? numbers : []).map((number) => String(number || "").trim()).filter(Boolean);
  if (cleaned.length === 0) return "";
  if (cleaned.every((number) => isNumberedEquationRef(number))) {
    return `Eq. (${cleaned.join(", ")})`;
  }
  return cleaned.join("；");
}

function latexFromFormulaLikeEquationNumbers(numbers) {
  const formulaParts = (Array.isArray(numbers) ? numbers : [])
    .map((number) => String(number || "").trim())
    .filter((number) => number && !isNumberedEquationRef(number) && /[=<>_\\^{}]/.test(number))
    .map((number) => number.replace(/sum_/g, "\\sum_"));
  return formulaParts.join("\\quad ");
}

function canonicalEquationBlockEntry(entry) {
  if (isPlainObject(entry)) {
    const equationNumbers = equationNumbersFromEntry(entry);
    const label = entry.label ?? entry.tag ?? entry.source_label ?? equationRefLabel(equationNumbers);
    const latex = entry.latex
      ?? entry.equation
      ?? entry.math
      ?? entry.formula
      ?? entry.tex
      ?? entry.display_latex
      ?? entry.source_latex
      ?? entry.text
      ?? entry.content
      ?? latexFromFormulaLikeEquationNumbers(equationNumbers);
    if (!isNonEmptyString(String(label ?? "")) && !isNonEmptyString(String(latex ?? "")) && equationNumbers.length === 0) {
      return null;
    }
    return {
      ...entry,
      type: "equation",
      label,
      latex,
      equation_numbers: equationNumbers.length > 0 ? equationNumbers : entry.equation_numbers,
      explanation: entry.explanation ?? entry.description ?? entry.caption ?? entry.notes ?? "",
    };
  }
  if (typeof entry === "string" || typeof entry === "number") {
    const latex = String(entry).trim();
    if (!latex) return null;
    return {
      type: "equation",
      label: "",
      latex,
      explanation: "",
    };
  }
  return null;
}

function appendCanonicalEquationBlock(target, seen, entry) {
  const canonical = canonicalEquationBlockEntry(entry);
  if (!canonical) return;
  const key = `${String(canonical.label || "").trim()}\n${String(canonical.latex || "").trim()}`;
  if (seen.has(key)) return;
  seen.add(key);
  target.push(canonical);
}

function equationBlockEntriesFromStructuredValue(value) {
  const entries = [];
  for (const key of ["equation_blocks", "visible_equation_blocks", "equations", "formulae"]) {
    if (Array.isArray(value?.[key])) {
      entries.push(...value[key]);
    }
  }
  return entries;
}

function equationBlocksFromStructuredValue(value) {
  const equations = [];
  const seen = new Set();
  const blocks = Array.isArray(value?.blocks) ? value.blocks : [];
  for (const block of blocks) {
    if (!isPlainObject(block)) continue;
    if (String(block.type || "").toLowerCase() !== "equation") continue;
    appendCanonicalEquationBlock(equations, seen, block);
  }
  for (const entry of equationBlockEntriesFromStructuredValue(value)) {
    appendCanonicalEquationBlock(equations, seen, entry);
  }
  return equations;
}

function normalizeSlideEquationBlockAliases(slide) {
  if (!isPlainObject(slide) || !Array.isArray(slide.visible_equation_blocks) || slide.visible_equation_blocks.length === 0) {
    return slide;
  }
  const normalizedEquationBlocks = equationBlockEntriesFromStructuredValue({
    equation_blocks: Array.isArray(slide.equation_blocks) ? slide.equation_blocks : [],
    visible_equation_blocks: slide.visible_equation_blocks,
  }).map((entry) => canonicalEquationBlockEntry(entry)).filter(Boolean);
  const next = {
    ...slide,
    equation_blocks: normalizedEquationBlocks,
  };
  delete next.visible_equation_blocks;
  return next;
}

function normalizeSlideShapeAliases(slide) {
  if (!isPlainObject(slide)) return slide;
  let next = slide;
  const setAlias = (key, value) => {
    if (!isNonEmptyString(value) || isNonEmptyString(next[key])) return;
    if (next === slide) next = { ...slide };
    next[key] = String(value).trim();
  };
  setAlias("kind", slide.page_kind ?? slide.page_role ?? slide.slide_kind ?? slide.role);
  setAlias("bucket", slide.bucket ?? slide.part ?? slide.ownership);
  if (isNonEmptyString(next.bucket)) {
    const normalizedBucket = String(next.bucket).trim().toLowerCase();
    if (["front_matter", "frontmatter", "opening", "title"].includes(normalizedBucket)) {
      if (next === slide) next = { ...slide };
      next.bucket = "body";
    }
  }
  if (!Array.isArray(next.bullets)) {
    const planned = Array.isArray(slide.planned_visible_bullets)
      ? slide.planned_visible_bullets
      : (Array.isArray(slide.planned_visible_elements) ? slide.planned_visible_elements : null);
    if (planned && planned.length > 0) {
      if (next === slide) next = { ...slide };
      next.bullets = planned;
    }
  }
  return next;
}

function normalizeSlideCollection(slides) {
  if (!Array.isArray(slides)) return [];
  let changedAny = false;
  const normalized = slides.map((slide) => {
    const nextSlide = normalizeSlideShapeAliases(normalizeSlideEquationBlockAliases(slide));
    if (nextSlide !== slide) {
      changedAny = true;
    }
    return nextSlide;
  });
  return changedAny ? normalized : slides;
}

module.exports = {
  equationBlockEntriesFromStructuredValue,
  equationBlocksFromStructuredValue,
  normalizeSlideCollection,
  normalizeSlideEquationBlockAliases,
  normalizeSlideShapeAliases,
};


path: scripts/slide_symbol_scanner.js
content: #!/usr/bin/env node
/**
 * slide_symbol_scanner.js — Deterministically correct notation_coverage in slides.json
 *
 * Scans every slide's visible content fields (matching pipeline's slideVisibleTextFromPlan),
 * uses pipeline's own matching functions (textContainsSymbolCandidate, symbolCandidatesFromNotationEntry),
 * and rewrites notation_coverage so every entry's first_defined_slide_ids / used_slide_ids
 * are backed by actual visible symbol presence.
 *
 * Usage:
 *   node slide_symbol_scanner.js --fix <slides.json>
 *   node slide_symbol_scanner.js --check <slides.json>    (dry-run, report issues)
 */

const fs = require("fs");
const path = require("path");

const PIPELINE_DIR = path.resolve(__dirname);

function loadCanonModule() {
  const canonPath = path.join(PIPELINE_DIR, "deck_symbol_canonicalization.js");
  const canon = require(canonPath);
  if (!canon || typeof canon.textContainsSymbolCandidate !== "function") {
    console.error("FATAL: deck_symbol_canonicalization.js did not export required functions");
    process.exit(1);
  }
  return canon;
}

// ─── slideVisibleTextFromPlan (matches pipeline behavior) ──────

function appendVisibleTextFragments(target, value) {
  if (typeof value === "string" || typeof value === "number") {
    const text = String(value).trim();
    if (text) target.push(text);
    return;
  }
  if (Array.isArray(value)) {
    for (const item of value) appendVisibleTextFragments(target, item);
  }
}

function visibleFragmentsFromBlock(block) {
  if (!block || typeof block !== "object") return [];
  const fragments = [];
  for (const key of [
    "title", "subtitle", "heading", "header", "caption", "label",
    "latex", "text", "content", "paragraph", "body", "claim",
    "summary", "description", "explanation", "legend", "alt_text", "alt", "value",
  ]) { appendVisibleTextFragments(fragments, block[key]); }
  for (const key of ["items", "bullets", "points", "list", "lines", "paragraphs", "captions"]) {
    appendVisibleTextFragments(fragments, block[key]);
  }
  for (const key of ["blocks", "children", "columns", "left", "right"]) {
    const nested = block[key];
    if (Array.isArray(nested)) {
      for (const item of nested) fragments.push(...visibleFragmentsFromBlock(item));
    } else if (nested && typeof nested === "object") {
      fragments.push(...visibleFragmentsFromBlock(nested));
    }
  }
  return [...new Set(fragments)];
}

function slideVisibleTextFromPlan(slide) {
  if (!slide || typeof slide !== "object") return "";
  const fragments = [];
  for (const key of ["title", "subtitle", "headline", "caption", "core_message", "summary", "description"]) {
    appendVisibleTextFragments(fragments, slide[key]);
  }
  for (const key of ["bullets", "items", "points", "list", "lines", "equations"]) {
    appendVisibleTextFragments(fragments, slide[key]);
  }
  for (const key of ["defines_symbols", "used_symbols"]) {
    appendVisibleTextFragments(fragments, slide[key]);
  }
  const eqs = Array.isArray(slide.equation_blocks) ? slide.equation_blocks : [];
  for (const eq of eqs) {
    if (typeof eq?.latex === "string" && eq.latex.trim()) {
      appendVisibleTextFragments(fragments, eq.latex.trim());
    }
    if (typeof eq?.explanation === "string" && eq.explanation.trim()) {
      appendVisibleTextFragments(fragments, eq.explanation.trim());
    }
  }
  const blocks = Array.isArray(slide.blocks) ? slide.blocks : [];
  for (const block of blocks) fragments.push(...visibleFragmentsFromBlock(block));
  return [...new Set(fragments)].join("\n");
}

// ─── Structural command filter ─────────────────────────────────
const STRUCTURAL_LATEX = new Set([
  // Spacing
  "\\quad", "\\qquad", "\\hspace", "\\vspace", "\\;", "\\,", "\\!", "\\:",
  // Delimiters
  "\\left", "\\right", "\\bigl", "\\bigr", "\\big", "\\Big", "\\bigg", "\\Bigg",
  "\\lvert", "\\rvert", "\\lVert", "\\rVert", "\\langle", "\\rangle", "\\mid",
  // Accents (modify other symbols, not standalone notation)
  "\\hat", "\\tilde", "\\bar", "\\vec", "\\dot", "\\ddot", "\\mathring",
  "\\check", "\\breve", "\\acute", "\\grave",
  // Standard math functions/operators
  "\\ln", "\\log", "\\exp", "\\sin", "\\cos", "\\tan", "\\cot", "\\sec", "\\csc",
  "\\max", "\\min", "\\sup", "\\inf", "\\lim", "\\det", "\\dim", "\\gcd", "\\hom",
  "\\arg", "\\deg", "\\Pr", "\\ker",
  // Integral/sum/product symbols
  "\\int", "\\iint", "\\iiint", "\\oint", "\\sum", "\\prod", "\\coprod",
  "\\bigcup", "\\bigcap", "\\bigvee", "\\bigwedge",
  // Standard constants/symbols
  "\\infty", "\\pi", "\\emptyset", "\\partial", "\\nabla", "\\Delta",
  "\\cdot", "\\cdots", "\\ldots", "\\vdots", "\\ddots",
  "\\colon", "\\to", "\\mapsto", "\\Rightarrow", "\\Leftrightarrow",
  "\\times", "\\otimes", "\\oplus", "\\pm", "\\mp",
  // Fractions
  "\\frac", "\\tfrac", "\\dfrac", "\\binom",
  // Roots
  "\\sqrt",
  // Structural
  "\\begin", "\\end", "\\array", "\\hline", "\\toprule", "\\midrule", "\\bottomrule",
  "\\label", "\\ref", "\\eqref", "\\cite", "\\tag", "\\nonumber", "\\displaystyle",
  "\\textstyle", "\\scriptstyle", "\\scriptscriptstyle",
  // Typographic wrappers (single-letter arg → typographic, multi-letter → keep as notation)
  // These are handled specially below, not in the Set
]);

const TYPOGRAPHIC_ARG_PATTERN = /^\\(?:mathrm|mathbf|mathcal|mathbb|mathit|mathsf|mathtt|operatorname|text)\{(\{?[^}]+\}?)\}$/;

const STRUCTURAL_PREFIXES = [
  "\\begin{", "\\end{", "\\tag{", "\\frac{", "\\tfrac{", "\\dfrac{", "\\binom{",
  "\\sqrt{",
  "\\displaystyle", "\\textstyle",
];

function isStructuralLatexCommand(raw) {
  // Direct structural match
  if (STRUCTURAL_LATEX.has(raw)) return true;
  // Prefix match (e.g. \begin{aligned}, \tag{2.1}, \frac{...})
  for (const prefix of STRUCTURAL_PREFIXES) {
    if (raw.startsWith(prefix) && raw !== prefix) return true;
  }
  // Match \mathbf{X} etc where X is a single letter → typographic
  const typoMatch = raw.match(TYPOGRAPHIC_ARG_PATTERN);
  if (typoMatch) {
    const arg = typoMatch[1].replace(/[{}]/g, "").trim();
    // Single letter = typographic variant of standard math
    if (/^[a-zA-Z]$/.test(arg)) return true;
    // Common typographic words
    if (/^(d|i|e|dx|dy|dt|id|tr|Re|Im|mod|rank|span|diag|trace|s\.t\.|w\.r\.t\.)$/i.test(arg)) return true;
    // Multi-letter words like Corr, Real, Market are notation → keep
  }
  // Custom shorthand macros used ONLY as typographic/structural helpers
  if (/^\\(?:dv|dr|pdv|odv|dd|dvD|odvD|pdvD)$/.test(raw)) return true;
  // \d, \D — custom differential 'd' (typographic choice, notation is dW_t etc)
  if (/^\\[dD]$/.test(raw)) return true;
  // \k, \c — single lowercase letter macros commonly used as kerning/shorthand
  if (/^\\[kc]$/.test(raw)) return true;
  return false;
}

// ─── Main scan + fix logic ─────────────────────────────────────

function scanAndFix(slidesPath, dryRun, analysisPath = "") {
  const canon = loadCanonModule();
  const { textContainsSymbolCandidate, symbolCandidatesFromNotationEntry } = canon;

  const raw = JSON.parse(fs.readFileSync(slidesPath, "utf8"));
  const slides = raw.slides || [];
  const notationCoverage = Array.isArray(raw.notation_coverage) ? raw.notation_coverage : [];

  // Build slide text cache
  const slideTextCache = new Map();
  for (const s of slides) {
    slideTextCache.set(s.slide_id, slideVisibleTextFromPlan(s));
  }

  // ─── Phase 1: Correct existing entries ───────────────────────
  let fixes = 0;
  for (const entry of notationCoverage) {
    const symbol = entry.symbol || "";
    if (!symbol) continue;

    const candidates = symbolCandidatesFromNotationEntry(symbol);
    if (candidates.length === 0) {
      console.log(`[skip] ${symbol}: no candidates generated`);
      continue;
    }

    // Verify & fix first_defined_slide_ids
    const currentFirst = (Array.isArray(entry.first_defined_slide_ids)
      ? entry.first_defined_slide_ids : []).map(s => String(s).trim()).filter(Boolean);
    const validFirst = [];
    const invalidFirst = [];
    for (const sid of currentFirst) {
      const text = slideTextCache.get(sid) || "";
      const visible = candidates.some(c => textContainsSymbolCandidate(c, text));
      if (visible) validFirst.push(sid);
      else invalidFirst.push(sid);
    }
    if (invalidFirst.length > 0) {
      // Try to find the earliest slide where symbol is actually visible
      const visibleSlides = [];
      for (const s of slides) {
        const text = slideTextCache.get(s.slide_id) || "";
        if (candidates.some(c => textContainsSymbolCandidate(c, text))) {
          visibleSlides.push(s.slide_id);
        }
      }
      if (visibleSlides.length > 0) {
        entry.first_defined_slide_ids = [visibleSlides[0]];
        if (dryRun) {
          console.log(`[fix] ${symbol}: first_defined ${currentFirst.join(",")} → ${visibleSlides[0]} (${invalidFirst.join(",")} not visible)`);
        }
        fixes++;
      } else {
        if (dryRun) console.log(`[warn] ${symbol}: not visible on ANY slide, keeping ${currentFirst.join(",")}`);
      }
    }

    // Verify & fix used_slide_ids
    const currentUsed = (Array.isArray(entry.used_slide_ids)
      ? entry.used_slide_ids : []).map(s => String(s).trim()).filter(Boolean);
    const validUsed = [];
    let usedChanged = false;
    for (const sid of currentUsed) {
      const text = slideTextCache.get(sid) || "";
      const visible = candidates.some(c => textContainsSymbolCandidate(c, text));
      if (visible) validUsed.push(sid);
      else usedChanged = true;
    }
    if (usedChanged) {
      entry.used_slide_ids = validUsed;
      if (dryRun) console.log(`[fix] ${symbol}: used_slide_ids filtered to ${validUsed.length} visible`);
      fixes++;
    }
  }

  // ─── Phase 2: Add missing symbols from equation_blocks ───────
  const trackedSymbols = new Set(notationCoverage.map(e => e.symbol).filter(Boolean));
  const missingEntries = [];

  for (const slide of slides) {
    const eqs = Array.isArray(slide.equation_blocks) ? slide.equation_blocks : [];
    for (const eq of eqs) {
      const latex = typeof eq?.latex === "string" ? eq.latex.trim() : "";
      if (!latex) continue;

      // Extract LaTeX macro candidates: \alpha_1, \beta, \mathrm{Corr}, etc.
      const macroPattern = /\\[a-zA-Z]+(?:\{[^}]*\})?(?:_\{[^}]*\})?(?:\^\{[^}]*\})?/g;
      let macroMatch;
      while ((macroMatch = macroPattern.exec(latex)) !== null) {
        const raw = macroMatch[0];
        // Skip structural/typographic commands (not mathematical notation)
        if (isStructuralLatexCommand(raw)) continue;
        if (!trackedSymbols.has(raw)) {
          trackedSymbols.add(raw);
          // Find all slides where this symbol is visible
          const candidates = symbolCandidatesFromNotationEntry(raw);
          const visibleSlides = [];
          for (const s of slides) {
            const text = slideTextCache.get(s.slide_id) || "";
            if (candidates.some(c => textContainsSymbolCandidate(c, text))) {
              visibleSlides.push(s.slide_id);
            }
          }
          if (visibleSlides.length > 0) {
            missingEntries.push({
              symbol: raw,
              status: "defined",
              first_defined_slide_ids: [visibleSlides[0]],
              used_slide_ids: visibleSlides,
              meaning: awaitMeaning(raw, latex),  // basic heuristic
              source_paragraph_ids: [],
              source_quote: "",
              source_definition_summary: "",
              defined_on_first_visible_use: true,
            });
            if (dryRun) console.log(`[add] ${raw}: first on ${visibleSlides[0]}, used on ${visibleSlides.length} slides`);
            fixes++;
          }
        }
      }
    }
  }

  // ─── Phase 3: Scan analysis.json for symbols missed by Phase 2 ───────
  // Phase 2 only scans equation_blocks. Symbols appearing only in bullets/core_message
  // or in the source paper's equations but not tagged as equation_blocks get caught here.
  if (analysisPath && fs.existsSync(analysisPath)) {
    try {
      const analysisDoc = JSON.parse(fs.readFileSync(analysisPath, "utf8"));
      const eqs = Array.isArray(analysisDoc.equations) ? analysisDoc.equations : [];
      const ledger = Array.isArray(analysisDoc.paragraph_ledger) ? analysisDoc.paragraph_ledger : [];

      // Collect all LaTeX source text
      const sourceTextParts = [];
      for (const eq of eqs) {
        const latex = typeof eq?.latex === "string" ? eq.latex.trim() : "";
        if (latex) sourceTextParts.push(latex);
        const explanation = typeof eq?.explanation === "string" ? eq.explanation.trim() : "";
        if (explanation) sourceTextParts.push(explanation);
      }
      for (const p of ledger) {
        const text = typeof p?.text === "string" ? p.text : "";
        if (text) sourceTextParts.push(text);
      }
      const sourceText = sourceTextParts.join(" ");

      const macroPattern2 = /\\[a-zA-Z]+(?:\{[^}]*\})?(?:_\{[^}]*\})?(?:\^\{[^}]*\})?/g;
      let srcMacro;
      while ((srcMacro = macroPattern2.exec(sourceText)) !== null) {
        const raw = srcMacro[0];
        if (isStructuralLatexCommand(raw)) continue;
        if (trackedSymbols.has(raw)) continue;

        trackedSymbols.add(raw);
        const candidates = symbolCandidatesFromNotationEntry(raw);
        const visibleSlides = [];
        for (const s of slides) {
          const text = slideTextCache.get(s.slide_id) || "";
          if (candidates.some(c => textContainsSymbolCandidate(c, text))) {
            visibleSlides.push(s.slide_id);
          }
        }

        if (visibleSlides.length > 0) {
          missingEntries.push({
            symbol: raw,
            status: "defined",
            first_defined_slide_ids: [visibleSlides[0]],
            used_slide_ids: visibleSlides,
            meaning: awaitMeaning(raw, sourceText),
            source_paragraph_ids: [],
            source_quote: "",
            source_definition_summary: "",
            defined_on_first_visible_use: true,
          });
          if (dryRun) console.log(`[add:analysis] ${raw}: first on ${visibleSlides[0]}, used on ${visibleSlides.length} slides`);
          fixes++;
        } else {
          if (dryRun) console.log(`[warn:analysis] ${raw}: found in analysis.json but not visible in any slide (will need post-compile main.tex scan)`);
        }
      }
    } catch (err) {
      console.warn(`[warn] Phase 3 analysis.json scan failed: ${err.message}`);
    }
  }

  raw.notation_coverage = [...notationCoverage, ...missingEntries];
  raw.notation_coverage_last_fixed_by = "slide_symbol_scanner";
  raw.notation_coverage_last_fixed_at = new Date().toISOString();

  if (!dryRun && fixes > 0) {
    fs.writeFileSync(slidesPath, JSON.stringify(raw, null, 2), "utf8");
  }

  console.log(`\n[scan] ${notationCoverage.length} entries scanned, ${missingEntries.length} added, ${fixes} total fixes${dryRun ? " (dry-run)" : " (applied)"}`);
  return fixes;
}

function awaitMeaning(symbol, contextLatex) {
  // Simple heuristics
  const map = {
    "\\alpha": "风险中性参数/系数",
    "\\beta": "均值回复速度",
    "\\gamma": "波动率参数",
    "\\delta": "利率参数/贴现因子",
    "\\epsilon": "误差项",
    "\\theta": "长期均值",
    "\\kappa": "均值回复速度",
    "\\lambda": "特征值/强度参数",
    "\\mu": "漂移项/均值",
    "\\sigma": "波动率",
    "\\rho": "相关系数",
    "\\tau": "到期时间",
    "\\phi": "特征函数",
    "\\psi": "特征函数/辅助函数",
    "\\omega": "参数/权重",
    "\\xi": "辅助变量",
    "\\Sigma": "协方差矩阵",
    "\\Omega": "参数空间/矩阵",
  };
  return map[symbol] || `符号 ${symbol}`;
}

// ─── CLI ───────────────────────────────────────────────────────
function main() {
  const args = process.argv.slice(2);
  let dryRun = false;
  let targetPath = "";
  let analysisPath = "";

  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--check") dryRun = true;
    else if (args[i] === "--fix") dryRun = false;
    else if (args[i] === "--analysis" && i + 1 < args.length) { analysisPath = args[i + 1]; i++; }
    else if (!args[i].startsWith("--")) targetPath = args[i];
  }

  if (!targetPath) {
    console.error("Usage: node slide_symbol_scanner.js [--check|--fix] <path-to-slides.json>");
    process.exit(1);
  }

  if (!fs.existsSync(targetPath)) {
    console.error("File not found:", targetPath);
    process.exit(1);
  }

  scanAndFix(targetPath, dryRun, analysisPath);
}

main();


path: templates/beamer_contract.md
content: # Beamer generation contract

Use this contract when converting a paper or paper notes into a Beamer package.

## Required deliverables

Write all deliverables into one canonical output directory:

- `analysis.json`
- `slides.json`
- `main.tex`
- `main.pdf` if compilation succeeds
- `README.md`
- `asset_manifest.json`
- `figures/`

## Content requirements

- Preserve every source figure, table, subfigure, and appendix figure/table that is available from the source.
- If content does not fit, split across more frames/pages; do not replace source material with “see paper”.
- Treat any requested page count as a lower bound, not an upper bound.
- Use Chinese academic presentation prose suitable for an audience that has not read the paper.
- Include background, method, formal results, numerical study, insights, and conclusion significance.
- Separate formal mathematical statements from economic/technical intuition when dense.
- Translate crucial theorem/proposition/lemma/corollary/definition/assumption statements faithfully and fully into Chinese.
- Give numerical experiments dedicated coverage rather than burying them in an appendix index.
- Give each insight its own page when needed.

## Visible text constraints

Do not render internal planning metadata into visible Beamer text. Keep these only in JSON metadata or notes:

- `core_message`
- `source_paragraph_ids`
- `paragraph_ledger`
- coverage tracking labels
- speaker-note scaffolding
- `核心信息`
- `来源段落`
- `这页负责`
- `服务于未读论文听众`

Use natural academic wording instead of internal labels.

## Equation and notation requirements

- `equation_coverage` may mark an equation as covered only if the corresponding frame visibly contains an equation/align/math display.
- Do not count a prose-only mention or notes-only mention as equation coverage.
- If the source has a continuous numbered equation range with 3 or more equations, do not show only one representative formula and claim the whole range is covered.
- Preserve exact equation numbers or ranges when available, for example `Eq. (1)`, `Eqs. (2)-(3)`, `Eq. (13)-(16)`.
- Define abbreviations, symbols, operators, and hyperparameters on first visible use using source-supported definitions.
- `notation_coverage` should include `source_paragraph_ids`, `source_quote`, `source_definition_summary`, and `defined_on_first_visible_use=true` when applicable.

## Final acceptance fields

The final report should expose structured acceptance fields:

- `compile_status`
- `readability_status`
- `tex_warnings`
- `layout_policy`
- `visible_prose_recovery_hint`
- `visible_prose_fidelity_final`
- `render_fidelity_safeguards`


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)