Skip to content

v5.2.0 Branch Completion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bring release/v5.2.0 to a branch-complete state — public docs, version chips, frontend, Cloudflare configuration, and Jira release manifest all align with the CSP architecture that landed in PHON-109.

Architecture: Six workstreams land sequentially as commits directly on release/v5.2.0 (no nested PRs): docs rewrite, version bump, PHON-114 Containers config, PHON-110 frontend rewrite (the largest), PHON-90 UI/UX audit, PHON-111 contrastive parity, plus Jira hygiene. Merge to develop/main is out of scope.

Tech Stack: TypeScript + Hono + Vitest (Cloudflare Workers); React + MUI + Vitest + happy-dom (frontend); Python 3.10/11 + FastAPI + Polars + uvicorn (CSP server); Cloudflare Containers (open beta) via Durable Objects binding; Docker for image builds.

Spec: docs/superpowers/specs/2026-05-10-v5-2-0-branch-completion-design.md


Workstream 1: Public-facing copy

Task 1: Rewrite README.md and CONTRIBUTING.md for CSP architecture

Files: - Modify: README.md (lines 15, 86, 132-185, plus surrounding context) - Modify: CONTRIBUTING.md (line 70)

Why: README + CONTRIBUTING still describe T5Gemma 9B-2B + RunPod + 25GB GPU. The architecture is now CSP — constraint solver + 4-axis LightGBM reranker, no LM, CPU-only.

  • [ ] Step 1: Read both files end-to-end

Run: cat README.md CONTRIBUTING.md

This is necessary to spot every surface that needs the rewrite — not just lines 15, 86, 132, 183. Surrounding paragraphs may also reference T5Gemma indirectly.

  • [ ] Step 2: Rewrite README.md "Governed generation" bullet (around line 15)

Replace the T5Gemma bullet with:

- **Governed generation** — constraint-driven CSP sentence/paragraph generation with per-axis quality reranker. No LM, CPU-only (~600MB runtime), ~25s cold start, ~1-2s warm requests.
  • [ ] Step 3: Rewrite README.md "System requirements" (around line 86)

Replace any "Requires a GPU or Apple Silicon Mac with ~25GB memory for T5Gemma" with:

**Generation server:** runs on CPU. Cold-starts ~25s while loading runtime parquets (~600MB) + MiniLM-L6-v2 (~80MB) + reranker_v2.pkl (~80MB). Warm requests ~1-2s.
  • [ ] Step 4: Rewrite README.md pipeline diagram + module list (around lines 132 and 183)

The diagram around line 132 calls packages/generation/server/ "FastAPI + T5Gemma 9B-2B (governed generation)". Replace with:

packages/generation/server/      FastAPI hosting CSP (constraint solver + 4-axis LightGBM reranker)

The module list around line 183 references model.py (T5Gemma loading) — that file is gone. Replace with the actual current files: main.py (FastAPI cold-start + lifespan), schemas.py (request/response types), routes/generate.py (3 endpoints).

  • [ ] Step 5: Rewrite CONTRIBUTING.md line 70

Change - packages/generation/ — Governed generation server (FastAPI + T5Gemma) to:

- `packages/generation/` — Governed generation server (FastAPI hosting CSP solver + reranker_v2; CPU-only, ~25s cold start)
  • [ ] Step 6: Grep for any remaining T5Gemma / RunPod references in user-facing files

Run:

grep -rn "T5Gemma\|t5gemma\|T5GEMMA\|RunPod\|runpod" \
  --include="*.md" \
  --exclude-dir=node_modules --exclude-dir=research --exclude-dir=docs \
  README.md CONTRIBUTING.md

Expected: no matches in README.md or CONTRIBUTING.md after Steps 2-5. (Matches in docs/, memory/, research/ are historical record and stay as-is.)

  • [ ] Step 7: Commit
git add README.md CONTRIBUTING.md
git commit -m "$(cat <<'EOF'
docs: rewrite README + CONTRIBUTING for CSP architecture

T5Gemma 9B-2B and RunPod are retired (PHON-109). Generation now
runs on CPU via constraint-driven CSP + 4-axis LightGBM reranker
in packages/generation/server/, ~600MB RAM, ~25s cold start.
EOF
)"

Workstream 2: Version bump

Task 2: Bump 5.1.0 → 5.2.0 across frontend + workers + chip + drawers

Files: - Modify: packages/web/frontend/package.json (line 4) - Modify: packages/web/frontend/src/components/AppHeader.tsx (lines 129, 507, 579) - Modify: packages/web/workers/package.json (line 3)

Why: Chips/footers still read v5.1.0; per-memory checklist must update all four spots.

  • [ ] Step 1: Update packages/web/frontend/package.json

Change "version": "5.1.0" to "version": "5.2.0".

  • [ ] Step 2: Update packages/web/workers/package.json

Change "version": "5.1.0" to "version": "5.2.0".

  • [ ] Step 3: Update packages/web/frontend/src/components/AppHeader.tsx

Three string changes: - Line 129: label="v5.1.0"label="v5.2.0" - Line 507: PhonoLex v5.1.0 • Built with React + TypeScript + HonoPhonoLex v5.2.0 • Built with React + TypeScript + Hono - Line 579: PhonoLex v5.1.0 • Neumann's Workshop, LLCPhonoLex v5.2.0 • Neumann's Workshop, LLC

  • [ ] Step 4: Verify no stragglers

Run:

grep -rn "5\.1\.0\|v5\.1" \
  --include="*.ts" --include="*.tsx" --include="*.json" --include="*.toml" \
  --exclude-dir=node_modules --exclude-dir=dist \
  packages/web/

Expected: only matches in node_modules/ (excluded) and unrelated semver strings (e.g., ^5.1.0 for an external dep). No v5.1.0 references in our source.

  • [ ] Step 5: Type-check

Run: cd packages/web/frontend && npx tsc --noEmit && cd ../workers && npx tsc --noEmit Expected: no errors.

  • [ ] Step 6: Commit
git add packages/web/frontend/package.json packages/web/frontend/src/components/AppHeader.tsx packages/web/workers/package.json
git commit -m "$(cat <<'EOF'
chore: bump version to 5.2.0

Updates package.json (frontend + workers) and AppHeader chip + footer + drawer.
EOF
)"

Workstream 3: PHON-114 Cloudflare Containers configuration

Task 3: Author Dockerfile + .dockerignore for the generation server

Files: - Create: packages/generation/server/Dockerfile - Create: packages/generation/server/.dockerignore

Why: Cloudflare Containers reads the Dockerfile at build time. We need a self-contained image that includes the runtime parquet + reranker artifacts.

  • [ ] Step 1: Write the Dockerfile

Path: packages/generation/server/Dockerfile. Content:

# syntax=docker/dockerfile:1.7

FROM python:3.11-slim AS base

# Build deps for any C-extension wheels (numpy/polars usually have wheels but be safe).
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
    && rm -rf /var/lib/apt/lists/*

# Install uv for dependency management.
RUN pip install --no-cache-dir uv==0.5.11

WORKDIR /app

# Copy workspace pyproject + per-package metadata first to maximize layer caching.
COPY pyproject.toml uv.lock /app/
COPY packages/data/pyproject.toml /app/packages/data/pyproject.toml
COPY packages/governors/pyproject.toml /app/packages/governors/pyproject.toml
COPY packages/generators/pyproject.toml /app/packages/generators/pyproject.toml
COPY packages/generation/pyproject.toml /app/packages/generation/pyproject.toml

# Copy source for the editable installs.
COPY packages/data /app/packages/data
COPY packages/governors /app/packages/governors
COPY packages/generators /app/packages/generators
COPY packages/generation /app/packages/generation

# Install the workspace (editable so phonolex_* imports resolve).
RUN uv pip install --system -e packages/data -e packages/governors -e packages/generators -e packages/generation

# Bake the runtime artifacts into the image.
COPY data/runtime /app/data/runtime

ENV PYTHONUNBUFFERED=1
EXPOSE 8000

# CSP cold start ~25s; tune workers for single-instance container (DO sticky routing).
CMD ["uvicorn", "packages.generation.server.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

Notes for the engineer: - The exact layer order maximizes Docker's layer cache. Pyproject metadata changes infrequently relative to source code; pinning the install layer to source files means iterations only invalidate the source-copy and install layers, not the apt/pip-install-uv layers. - --workers 1 is intentional: each container instance hosts a single CSP solver state (WordStore + selectional + skeletons + reranker — see packages/generation/server/main.py lifespan). Multiple workers would multiply ~600MB RAM with no concurrency win.

  • [ ] Step 2: Write the .dockerignore

Path: packages/generation/server/.dockerignore. Content:

# VCS
.git
.gitignore
.gitattributes

# Python build artifacts
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/*.egg-info
**/.venv
.venv

# Tests + research not needed at runtime
**/tests
**/test_*.py
**/research
research/

# Web stack — separate deploy target
packages/web

# Documentation + memory not needed in container
docs/
memory/

# Source datasets — only data/runtime/ is baked in
data/cmu
data/norms
data/mappings

# Editor / OS
.vscode
.idea
.DS_Store

# Plan + spec docs
*.md
!README.md
!CONTRIBUTING.md

The !README.md allow-list is just so the workspace root README doesn't accidentally get excluded if any package needs it; it's not strictly required since we explicitly COPY only the directories we want.

  • [ ] Step 3: Verify the image builds locally

Run from repo root:

cd /Users/jneumann/Repos/PhonoLex
docker build -f packages/generation/server/Dockerfile -t phonolex-generation:dev .

Expected: image builds successfully. First build will take several minutes (apt + pip + uv install + workspace install). Note final image size — should land ~700-1100MB depending on how the parquet artifacts compress.

If docker build is unavailable on the engineer's machine, document the failure but proceed: the Dockerfile syntax can be validated by docker buildx --check or just by the CF Containers build at deploy time. Don't block the workstream.

  • [ ] Step 4: Smoke-run the container locally (optional, recommended)

Run:

docker run --rm -p 8000:8000 phonolex-generation:dev

Wait ~25s for cold start; expected output includes:

[startup] loading WordStore from /app/data/runtime/words.parquet…
[startup] loading selectional.parquet…
[startup] loading skeletons.parquet…
[startup] warming reranker model…
[startup] ready.

In another terminal: curl http://localhost:8000/health{"status":"ok"}.

  • [ ] Step 5: Commit
git add packages/generation/server/Dockerfile packages/generation/server/.dockerignore
git commit -m "$(cat <<'EOF'
PHON-114: Dockerfile + .dockerignore for CSP server

Bakes data/runtime/*.parquet + reranker_v2.pkl into image; CMD runs
uvicorn against packages.generation.server.main:app with --workers 1
(single-instance state, sticky routing handled by CF Containers DO).
EOF
)"

Task 4: Worker GenerationServer Container class + index re-export + types + dep

Files: - Create: packages/web/workers/src/containers/generation.ts - Modify: packages/web/workers/src/index.ts - Modify: packages/web/workers/src/types.ts - Modify: packages/web/workers/package.json

Why: Cloudflare Containers requires a Durable Object class to wrap the container. The class must be exported from the Worker entry module.

  • [ ] Step 1: Verify the @cloudflare/containers package

Run:

cd /Users/jneumann/Repos/PhonoLex/packages/web/workers
npm view @cloudflare/containers version 2>&1 | head -5

If the package exists, note the latest version. If not, the Container base class may be exported from cloudflare:workers runtime — check the Cloudflare docs at https://developers.cloudflare.com/containers/get-started/. Update Step 4 imports accordingly.

  • [ ] Step 2: Add the @cloudflare/containers dep

Run from packages/web/workers/:

npm install --save @cloudflare/containers

If Step 1 showed the class lives in cloudflare:workers, skip the install — it's a runtime import, not an npm dep.

  • [ ] Step 3: Create the GenerationServer Container class

Path: packages/web/workers/src/containers/generation.ts. Content:

/**
 * GenerationServer — Cloudflare Container hosting the CSP generation backend.
 *
 * The container runs the FastAPI server from packages/generation/server/.
 * Worker routes call env.GENERATION_SERVICE.getByName('default').fetch(req)
 * to proxy requests; Cloudflare manages the container lifecycle (warm pool,
 * scale-to-zero, sticky routing via DO names).
 */
import { Container } from '@cloudflare/containers';

export class GenerationServer extends Container {
  defaultPort = 8000;
}

If Step 1 found the import lives at cloudflare:workers, change the import line accordingly.

  • [ ] Step 4: Re-export from the Worker entry

Modify packages/web/workers/src/index.ts. Find the section that exports default app (or similar) and add:

export { GenerationServer } from './containers/generation';

Cloudflare Workers requires DO classes to be exported from the entry module so the runtime can find them.

  • [ ] Step 5: Update the Env type

Modify packages/web/workers/src/types.ts. Find the Env interface (or type) and add the binding:

import type { GenerationServer } from './containers/generation';

export interface Env {
  // ... existing bindings (DB, etc.)
  GENERATION_SERVICE: DurableObjectNamespace<GenerationServer>;
}

Leave any existing GENERATION_SERVER_URL: string declaration in place for now; Task 5 removes it together with the route migration so the worker stays type-clean between commits.

  • [ ] Step 6: Type-check

Run: cd packages/web/workers && npx tsc --noEmit Expected: no errors. If @cloudflare/containers types are missing, install @cloudflare/workers-types if not already present (npm install --save-dev @cloudflare/workers-types).

  • [ ] Step 7: Commit
git add packages/web/workers/src/containers/ packages/web/workers/src/index.ts packages/web/workers/src/types.ts packages/web/workers/package.json packages/web/workers/package-lock.json
git commit -m "$(cat <<'EOF'
PHON-114: Worker GenerationServer DO class + Env binding

Add the Container class wrapping the CSP generation backend, re-export
from the Worker entry, and declare GENERATION_SERVICE on Env. The old
GENERATION_SERVER_URL string env var is removed in the next commit
together with the route migration.
EOF
)"

Task 5: wrangler.toml Containers binding + routes/generation.ts migration + tests

Files: - Modify: packages/web/workers/wrangler.toml - Modify: packages/web/workers/src/routes/generation.ts - Modify: packages/web/workers/src/types.ts (drop GENERATION_SERVER_URL) - Create: packages/web/workers/src/__tests__/generation.test.ts

Why: wrangler.toml needs the Containers binding so deploy-time picks up the image; the route handler switches from URL-fetch to binding-fetch; tests lock in the new shape.

  • [ ] Step 1: Add the Containers blocks to wrangler.toml

Modify packages/web/workers/wrangler.toml. Add at the top level (after the existing [[d1_databases]] block):

[[containers]]
class_name = "GenerationServer"
image = "../../generation/server/Dockerfile"
max_instances = 5

[[durable_objects.bindings]]
name = "GENERATION_SERVICE"
class_name = "GenerationServer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["GenerationServer"]

Mirror under [env.staging] (paste the same three blocks scoped under [[env.staging.containers]], [[env.staging.durable_objects.bindings]], [[env.staging.migrations]]).

Then delete the placeholder lines from both [vars] blocks (top-level and [env.staging.vars]):

GENERATION_SERVER_URL = ""  # set via wrangler secret put GENERATION_SERVER_URL in PHON-109b
  • [ ] Step 2: Migrate routes/generation.ts to the binding

Replace the entire file body with:

/**
 * Generation proxy — forwards requests to the CSP backend container.
 *
 * PHON-114: switched from URL-based fetch (env.GENERATION_SERVER_URL)
 * to native Cloudflare Containers binding (env.GENERATION_SERVICE).
 *
 * Routes:
 *   POST /generate-sentences   → backend /api/generate-sentences
 *   POST /generate-paragraphs  → backend /api/generate-paragraphs
 *   POST /generate-single      → backend /api/generate-single
 *   GET  /server/status        → backend /health
 */

import { Hono } from 'hono';
import type { Env } from '../types';
import { log } from '../lib/logger';

const generation = new Hono<{ Bindings: Env; Variables: { requestId: string } }>();


async function proxy(
  c: any,
  method: 'POST' | 'GET',
  path: string,
): Promise<Response> {
  const stub = c.env.GENERATION_SERVICE.getByName('default');
  const init: RequestInit = {
    method,
    headers: { 'Content-Type': 'application/json' },
  };
  if (method === 'POST') {
    init.body = await c.req.text();
  }
  try {
    const res = await stub.fetch(`http://generation-server${path}`, init);
    const body = await res.text();
    return new Response(body, {
      status: res.status,
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (e) {
    log('error', 'generation backend fetch failed', { context: { path, error: String(e) } });
    return c.json({ error: 'Generation backend unreachable' }, 502);
  }
}


generation.post('/generate-sentences', (c) => proxy(c, 'POST', '/api/generate-sentences'));
generation.post('/generate-paragraphs', (c) => proxy(c, 'POST', '/api/generate-paragraphs'));
generation.post('/generate-single', (c) => proxy(c, 'POST', '/api/generate-single'));
generation.get('/server/status', (c) => proxy(c, 'GET', '/health'));

export default generation;

The hostname in stub.fetch() is irrelevant — Container bindings ignore the host part of the URL — but a placeholder is required by fetch() semantics.

  • [ ] Step 3: Write the failing test

Path: packages/web/workers/src/__tests__/generation.test.ts. Content:

import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
import generation from '../routes/generation';

function makeStub(handler: (req: Request) => Promise<Response>) {
  return {
    fetch: vi.fn(async (urlOrRequest: string | Request, init?: RequestInit) => {
      const req = typeof urlOrRequest === 'string'
        ? new Request(urlOrRequest, init)
        : urlOrRequest;
      return handler(req);
    }),
  };
}

function makeEnv(stub: ReturnType<typeof makeStub>) {
  return {
    GENERATION_SERVICE: {
      getByName: vi.fn(() => stub),
    },
  };
}

describe('generation route — Containers binding', () => {
  it('POST /generate-sentences forwards body to container /api/generate-sentences', async () => {
    const stub = makeStub(async (req) => {
      expect(req.method).toBe('POST');
      expect(new URL(req.url).pathname).toBe('/api/generate-sentences');
      const body = await req.text();
      expect(JSON.parse(body)).toEqual({ spec: 'cat', band: 'b3' });
      return new Response(JSON.stringify({ candidates: [], n_total_candidates: 0, diagnostics: {} }), { status: 200 });
    });
    const env = makeEnv(stub);

    const app = new Hono<any>();
    app.route('/', generation);
    const res = await app.request('/generate-sentences', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ spec: 'cat', band: 'b3' }),
    }, env);

    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json).toHaveProperty('candidates');
    expect(env.GENERATION_SERVICE.getByName).toHaveBeenCalledWith('default');
  });

  it('GET /server/status maps to /health', async () => {
    const stub = makeStub(async (req) => {
      expect(req.method).toBe('GET');
      expect(new URL(req.url).pathname).toBe('/health');
      return new Response(JSON.stringify({ status: 'ok' }), { status: 200 });
    });
    const env = makeEnv(stub);

    const app = new Hono<any>();
    app.route('/', generation);
    const res = await app.request('/server/status', { method: 'GET' }, env);

    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json.status).toBe('ok');
  });

  it('returns 502 when the container fetch throws', async () => {
    const stub = {
      fetch: vi.fn(async () => { throw new Error('container unreachable'); }),
    };
    const env = {
      GENERATION_SERVICE: { getByName: vi.fn(() => stub) },
    };

    const app = new Hono<any>();
    app.route('/', generation);
    const res = await app.request('/generate-sentences', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: '{}',
    }, env);

    expect(res.status).toBe(502);
    const json = await res.json() as { error: string };
    expect(json.error).toMatch(/unreachable/);
  });
});
  • [ ] Step 4: Run the tests

Run: cd packages/web/workers && npx vitest run src/__tests__/generation.test.ts Expected: all 3 tests pass.

  • [ ] Step 5: Drop GENERATION_SERVER_URL from Env

Modify packages/web/workers/src/types.ts to remove the GENERATION_SERVER_URL: string field from the Env interface. This was kept in Task 4 for type-clean intermediate state; now that the route uses the binding, the URL is gone.

  • [ ] Step 6: Type-check the worker

Run: cd packages/web/workers && npx tsc --noEmit Expected: no errors. If wrangler.toml produces a binding-shape mismatch, regenerate types with npx wrangler types.

  • [ ] Step 7: Sanity-check wrangler config

Run: cd packages/web/workers && npx wrangler deploy --dry-run --env=staging 2>&1 | head -30 Expected: no errors about missing bindings or invalid syntax. Containers may be flagged "open beta" — that's fine.

  • [ ] Step 8: Commit
git add packages/web/workers/wrangler.toml packages/web/workers/src/routes/generation.ts packages/web/workers/src/types.ts packages/web/workers/src/__tests__/generation.test.ts
git commit -m "$(cat <<'EOF'
PHON-114: wrangler Containers binding + route migration to GENERATION_SERVICE

Switches the generation proxy from external HTTPS (GENERATION_SERVER_URL
env var) to native CF Containers binding via Durable Objects. Worker
routes now invoke env.GENERATION_SERVICE.getByName('default').fetch().
Drops GENERATION_SERVER_URL from the Env type and the wrangler.toml
[vars] blocks.

Tests cover happy-path proxy, /health → /server/status mapping, and
container-unreachable → 502.
EOF
)"

Workstream 4: PHON-110 Frontend rewrite

Task 6: Delete the orphan preflight.py server route

Files: - Delete: packages/generation/server/routes/preflight.py - Delete: packages/generation/server/__pycache__/governor.cpython-312.pyc (stale)

Why: preflight.py imports PreflightRequest/PreflightResponse from server.schemas (which doesn't define them post-PHON-109) and server.governor (deleted module). Not registered in main.py. Dead code that would crash if imported.

  • [ ] Step 1: Verify the file is genuinely orphan

Run:

grep -rn "from.*preflight\|import preflight" packages/generation/server/ \
  --include="*.py" \
  --exclude-dir=__pycache__ --exclude-dir=tests
Expected: no matches (the file isn't imported anywhere).

Also verify it's not registered in main.py:

grep -n "preflight" packages/generation/server/main.py
Expected: no matches.

  • [ ] Step 2: Delete the file + the stale pyc

Run:

rm packages/generation/server/routes/preflight.py
rm -f packages/generation/server/__pycache__/governor.cpython-312.pyc

  • [ ] Step 3: Verify the server still imports cleanly

Run:

cd /Users/jneumann/Repos/PhonoLex
uv run python -c "from packages.generation.server.main import app; print('ok')"
Expected: ok (no import errors).

  • [ ] Step 4: Run server tests if present

Run: cd packages/generation && uv run python -m pytest server/tests/ -v 2>&1 | tail -20 Expected: tests pass (or "no tests collected" if the dir is empty).

  • [ ] Step 5: Commit
git add -u packages/generation/server/
git commit -m "$(cat <<'EOF'
PHON-110 prep: remove orphan preflight.py server route

Imports were broken post-PHON-109 (PreflightRequest/PreflightResponse
gone from schemas.py; server.governor module deleted) and the route
wasn't registered in main.py. Frontend usePreflight is dropped in the
next commit.
EOF
)"

Task 7: Update types/governance.ts — add CSP types, replace Constraint union, drop v6 types

Files: - Modify: packages/web/frontend/src/types/governance.ts

Why: Frontend types must match packages/generation/server/schemas.py. Old union (5 types) excludes bound_boost's real shape and collapses 3 contrastive variants into one.

  • [ ] Step 1: Add the new CSP types to the bottom of governance.ts

Append to the file:

// ---------------------------------------------------------------------------
// CSP generation types (PHON-110) — mirrors packages/generation/server/schemas.py
// ---------------------------------------------------------------------------

export interface AxisScores {
  naturalness: number;
  grammaticality: number;
  age_appropriate: number;
  coherence: number;
}

export interface SentenceCandidate {
  sentence: string;
  verb: string;
  fillers: Record<string, string>;
  skeleton: string;
  axis_scores: AxisScores;
  composite_score: number;
  feature_distance: number;
  sonorant_diff: number;
  ppmi_total: number;
}

export interface ParagraphCandidate {
  discourse_subject: string;
  sentences: SentenceCandidate[];
  composite_score: number;
  axis_scores: AxisScores;
  score: number;
}

export interface GenerateSentencesRequest {
  spec: string;
  band: string;
  constraints: Constraint[];
  locked_slots?: Record<string, string>;
  axis_weights?: Record<keyof AxisScores, number>;
  top_k?: number;
}

export interface GenerateSentencesResponse {
  candidates: SentenceCandidate[];
  n_total_candidates: number;
  diagnostics: Record<string, unknown>;
}

export interface GenerateParagraphsRequest {
  spec: string;
  band: string;
  constraints: Constraint[];
  discourse_subject?: string;
  n_sentences?: number;
  use_pronoun_coref?: boolean;
  axis_weights?: Record<keyof AxisScores, number>;
  top_k?: number;
  per_sentence_top_k?: number;
  n_subject_seeds?: number;
}

export interface GenerateParagraphsResponse {
  paragraphs: ParagraphCandidate[];
  n_total_paragraphs: number;
  diagnostics: Record<string, unknown>;
}

export type GenerationMode = 'sentences' | 'paragraphs';
  • [ ] Step 2: Replace the legacy 5-type Constraint union with the 7-type union

In the file, find the existing BoundBoostConstraint and ContrastiveConstraint interfaces and the Constraint type union. Replace them with:

export interface BoundBoostConstraint {
  type: "bound_boost";
  norm: string;
  min_value?: number;
  max_value?: number;
}

export interface MinpairConstraint {
  type: "contrastive_minpair";
  phoneme1: string;
  phoneme2: string;
  position?: "initial" | "medial" | "final" | "any";
  slots?: [string, string];
}

export interface MaxoppConstraint {
  type: "contrastive_maxopp";
  phoneme1: string;
  phoneme2: string;
  position?: "initial" | "medial" | "final" | "any";
  min_sonorant_diff?: number;
  slots?: [string, string];
}

export interface MultoppConstraint {
  type: "contrastive_multopp";
  substitute: string;
  targets: string[];
  n_targets?: number;
  position?: "initial" | "medial" | "final" | "any";
}

export type Constraint =
  | ExcludeConstraint
  | IncludeConstraint
  | BoundConstraint
  | BoundBoostConstraint
  | MinpairConstraint
  | MaxoppConstraint
  | MultoppConstraint;

Note: server schema uses min_value/max_value (not min/max) on bound types. Keep BoundConstraint consistent — change its existing min/max to min_value/max_value while you're in the file:

export interface BoundConstraint {
  type: "bound";
  norm: string;
  min_value?: number;
  max_value?: number;
}
  • [ ] Step 3: Update the StoreEntry union to support 3 contrastive variants

Find the existing StoreEntry type and replace with:

export type StoreEntry =
  | { type: "exclude"; phoneme: string }
  | { type: "include"; phoneme: string; strength: number; targetRate?: number }
  | { type: "bound"; norm: string; direction: "min" | "max"; value: number }
  | { type: "bound_boost"; norm: string; direction: "min" | "max"; value: number }
  | { type: "contrastive_minpair"; phoneme1: string; phoneme2: string; position: "initial" | "medial" | "final" | "any" }
  | { type: "contrastive_maxopp"; phoneme1: string; phoneme2: string; position: "initial" | "medial" | "final" | "any"; minSonorantDiff: number }
  | { type: "contrastive_multopp"; substitute: string; targets: string[]; nTargets: number; position: "initial" | "medial" | "final" | "any" };

coverageTarget is dropped from bound_boost StoreEntry — the server's BoundBoostConstraint doesn't take a coverage target either; the boost is annotation-only and the reranker handles weight via axis_weights.

  • [ ] Step 4: Delete v6-only types

In the file, delete these declarations entirely (no consumers will exist after the rest of Workstream 4 lands):

  • RichToken
  • Phono (only consumed by RichToken)
  • WordViolation
  • WordComplianceDetail
  • BoostCoverage
  • AlternativeDraft
  • SingleGenerationResponse
  • GenerationEvent
  • GenerationResult
  • PreflightResponse
  • ServerStatus — keep this (still used by useServerStatus); just verify it stays.

  • [ ] Step 5: Type-check (will fail loudly)

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | head -40

Expected: many errors in files that consume the deleted types (generationApi.ts, OutputCard.tsx, OutputFeed.tsx, TokenDisplay.tsx, index.tsx, constraintCompiler.ts, etc.). The errors guide the next tasks.

Do not commit yet — type errors exist. Tasks 8-14 fix them.


Task 8: Rewrite lib/generationApi.ts

Files: - Modify: packages/web/frontend/src/lib/generationApi.ts

Why: Drop SSE parsing + preflight. Add two Promise-returning generator functions. Keep useServerStatus.

  • [ ] Step 1: Replace the file body

New content:

/**
 * Generation API client — talks to the CSP generation backend.
 *
 * Base URL is configurable via VITE_GENERATION_API_URL. Production points
 * at the Worker proxy (api.phonolex.com → Worker → CF Container). Dev
 * defaults to http://localhost:8000 (FastAPI direct).
 */

import { useState, useEffect, useRef } from 'react';
import type {
  GenerateSentencesRequest,
  GenerateSentencesResponse,
  GenerateParagraphsRequest,
  GenerateParagraphsResponse,
  ServerStatus,
} from '../types/governance';
import { freshRequestId, getRequestId, logError } from './logger';

const GENERATION_API_URL = import.meta.env.VITE_GENERATION_API_URL
  || import.meta.env.VITE_API_URL
  || 'http://localhost:8000';

async function postJson<TReq, TRes>(path: string, body: TReq): Promise<TRes> {
  const rid = freshRequestId();
  const res = await fetch(`${GENERATION_API_URL}${path}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Request-ID': rid,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const detail = await res.text().catch(() => res.statusText);
    logError('Generation API request failed', {
      method: 'POST',
      url: path,
      status: res.status,
      detail,
    });
    throw new Error(`Generation failed (${res.status}): ${detail}`);
  }

  return res.json();
}

export function generateSentences(
  req: GenerateSentencesRequest,
): Promise<GenerateSentencesResponse> {
  return postJson('/api/generate-sentences', req);
}

export function generateParagraphs(
  req: GenerateParagraphsRequest,
): Promise<GenerateParagraphsResponse> {
  return postJson('/api/generate-paragraphs', req);
}

export async function fetchServerStatus(): Promise<ServerStatus> {
  const res = await fetch(`${GENERATION_API_URL}/api/server/status`, {
    headers: { 'X-Request-ID': getRequestId() },
  });

  if (!res.ok) {
    throw new Error(`Server status check failed (${res.status})`);
  }

  return res.json();
}

/**
 * Hook that polls the generation server status every 5 seconds.
 *
 * Returns `{ status, hasFetched }`:
 * - `hasFetched` is `false` until the first successful response, then `true` forever.
 * - Transient poll errors after the first success preserve the last-known `status`
 *   rather than flipping back to `null`.
 */
export interface ServerStatusState {
  status: ServerStatus | null;
  hasFetched: boolean;
}

export function useServerStatus(pollIntervalMs = 5000): ServerStatusState {
  const [state, setState] = useState<ServerStatusState>({ status: null, hasFetched: false });
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function poll() {
      try {
        const s = await fetchServerStatus();
        if (!cancelled) setState({ status: s, hasFetched: true });
      } catch {
        if (cancelled) return;
        setState((prev) =>
          prev.hasFetched
            ? prev
            : { status: null, hasFetched: false },
        );
      }
    }

    poll();
    intervalRef.current = setInterval(poll, pollIntervalMs);

    return () => {
      cancelled = true;
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [pollIntervalMs]);

  return state;
}

Removed: generateContent, fetchPreflight, usePreflight, GenerationCallbacks interface. The path prefix is /api/... because the FastAPI server mounts the generate router at /api; the Worker proxy strips /api from its incoming path and forwards to the same /api/... on the backend (already wired in Task 5).

  • [ ] Step 2: Type-check this file

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | grep "generationApi\|lib/logger" Expected: no errors in generationApi.ts itself. Errors in consumers (Task 13) are expected and addressed there.

Do not commit yet — type errors elsewhere. Continue to Task 9.


Task 9: Update store/constraintStore.ts — new fields + 7-type StoreEntry handling

Files: - Modify: packages/web/frontend/src/store/constraintStore.ts

Why: Store needs new top-level state (mode, spec, band, axisWeights, topK, paragraph options) and the StoreEntry handlers must cover 3 contrastive variants instead of one.

  • [ ] Step 1: Read the existing store

Run: cat packages/web/frontend/src/store/constraintStore.ts

Identify: - Existing state interface (likely ConstraintStore) - Existing actions (likely addEntry, removeEntry, etc.) - Existing selectors

This task augments without rewriting.

  • [ ] Step 2: Augment the state interface

Add these fields to the store state (alongside existing entries array):

import type { GenerationMode, AxisScores } from '../types/governance';

export interface AxisWeights {
  naturalness: number;
  grammaticality: number;
  age_appropriate: number;
  coherence: number;
}

const DEFAULT_AXIS_WEIGHTS: AxisWeights = {
  naturalness: 0.25,
  grammaticality: 0.25,
  age_appropriate: 0.25,
  coherence: 0.25,
};

// Inside the existing interface for the store state, add:
//   mode: GenerationMode;
//   spec: string;
//   band: string;
//   axisWeights: AxisWeights;
//   topK: number;
//   nSentences: number;
//   perSentenceTopK: number;
//   nSubjectSeeds: number;
//   discourseSubject: string;
//   usePronounCoref: boolean;

Add corresponding setters:

//   setMode: (mode: GenerationMode) => void;
//   setSpec: (spec: string) => void;
//   setBand: (band: string) => void;
//   setAxisWeights: (weights: AxisWeights) => void;
//   setTopK: (topK: number) => void;
//   setNSentences: (n: number) => void;
//   setPerSentenceTopK: (n: number) => void;
//   setNSubjectSeeds: (n: number) => void;
//   setDiscourseSubject: (s: string) => void;
//   setUsePronounCoref: (b: boolean) => void;

Initial state values: mode: 'sentences', spec: '', band: 'b3', axisWeights: DEFAULT_AXIS_WEIGHTS, topK: 8, nSentences: 3, perSentenceTopK: 4, nSubjectSeeds: 3, discourseSubject: '', usePronounCoref: true.

The Zustand store implementation (see existing create<...>(...) call) gets each setter as (val) => set({ field: val }).

  • [ ] Step 3: Update the StoreEntry handler logic for 3 contrastive variants

The store presumably has a way to add/remove/identify entries. Anywhere it currently switches on entry.type === 'contrastive', expand to handle three keys: 'contrastive_minpair', 'contrastive_maxopp', 'contrastive_multopp'. The Zustand state shape (an array of StoreEntry) doesn't change semantically; just the discriminant values.

If there's a helper like entryKey(entry) that builds a unique ID per entry (so duplicates are de-duped), make sure it includes the new variant fields (e.g., nTargets for multopp, minSonorantDiff for maxopp).

  • [ ] Step 4: Type-check

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | grep -E "constraintStore|store/" Expected: errors in the store reduce; consumers' errors persist (handled later).

Do not commit yet — pause until Task 10.


Task 10: Update lib/constraintCompiler.ts + tests

Files: - Modify: packages/web/frontend/src/lib/constraintCompiler.ts - Modify: packages/web/frontend/src/lib/constraintCompiler.test.ts

Why: Compiler maps StoreEntry[]Constraint[]. Three contrastive variants need explicit branches; bound/bound_boost switch from min/max to min_value/max_value.

  • [ ] Step 1: Read the existing compiler + test

Run: cat packages/web/frontend/src/lib/constraintCompiler.ts packages/web/frontend/src/lib/constraintCompiler.test.ts

Identify the existing compile function shape and the existing test pattern.

  • [ ] Step 2: Write new failing test cases first

In constraintCompiler.test.ts, add (don't replace; existing exclude/include tests should stay):

describe('contrastive variants', () => {
  it('compiles minpair StoreEntry → contrastive_minpair Constraint', () => {
    const entries: StoreEntry[] = [
      { type: 'contrastive_minpair', phoneme1: 'p', phoneme2: 'b', position: 'initial' },
    ];
    expect(compileConstraints(entries)).toEqual([
      { type: 'contrastive_minpair', phoneme1: 'p', phoneme2: 'b', position: 'initial' },
    ]);
  });

  it('compiles maxopp StoreEntry → contrastive_maxopp with min_sonorant_diff', () => {
    const entries: StoreEntry[] = [
      { type: 'contrastive_maxopp', phoneme1: 's', phoneme2: 'l', position: 'any', minSonorantDiff: 0.7 },
    ];
    expect(compileConstraints(entries)).toEqual([
      { type: 'contrastive_maxopp', phoneme1: 's', phoneme2: 'l', position: 'any', min_sonorant_diff: 0.7 },
    ]);
  });

  it('compiles multopp StoreEntry → contrastive_multopp with targets[] + n_targets', () => {
    const entries: StoreEntry[] = [
      { type: 'contrastive_multopp', substitute: 'w', targets: ['l', 'r', 'j'], nTargets: 3, position: 'initial' },
    ];
    expect(compileConstraints(entries)).toEqual([
      { type: 'contrastive_multopp', substitute: 'w', targets: ['l', 'r', 'j'], n_targets: 3, position: 'initial' },
    ]);
  });
});

describe('bound + bound_boost use min_value/max_value', () => {
  it('compiles bound StoreEntry with direction=min → BoundConstraint{min_value}', () => {
    const entries: StoreEntry[] = [
      { type: 'bound', norm: 'concreteness', direction: 'min', value: 3.5 },
    ];
    expect(compileConstraints(entries)).toEqual([
      { type: 'bound', norm: 'concreteness', min_value: 3.5 },
    ]);
  });

  it('compiles bound_boost StoreEntry with direction=max → BoundBoostConstraint{max_value}', () => {
    const entries: StoreEntry[] = [
      { type: 'bound_boost', norm: 'aoa_kuperman', direction: 'max', value: 6 },
    ];
    expect(compileConstraints(entries)).toEqual([
      { type: 'bound_boost', norm: 'aoa_kuperman', max_value: 6 },
    ]);
  });
});
  • [ ] Step 3: Run the failing tests

Run: cd packages/web/frontend && npx vitest run src/lib/constraintCompiler.test.ts Expected: the 5 new tests fail.

  • [ ] Step 4: Update the compiler to handle the new variants

Modify compileConstraints in constraintCompiler.ts. Replace the old 'contrastive' case with three explicit branches; switch bound + bound_boost to emit min_value/max_value:

import type { StoreEntry, Constraint } from '../types/governance';

export function compileConstraints(entries: StoreEntry[]): Constraint[] {
  return entries.map((e) => {
    switch (e.type) {
      case 'exclude':
        return { type: 'exclude', phonemes: [e.phoneme] };
      case 'include':
        return { type: 'include', phonemes: [e.phoneme] };
      case 'bound':
        return e.direction === 'min'
          ? { type: 'bound', norm: e.norm, min_value: e.value }
          : { type: 'bound', norm: e.norm, max_value: e.value };
      case 'bound_boost':
        return e.direction === 'min'
          ? { type: 'bound_boost', norm: e.norm, min_value: e.value }
          : { type: 'bound_boost', norm: e.norm, max_value: e.value };
      case 'contrastive_minpair':
        return {
          type: 'contrastive_minpair',
          phoneme1: e.phoneme1,
          phoneme2: e.phoneme2,
          position: e.position,
        };
      case 'contrastive_maxopp':
        return {
          type: 'contrastive_maxopp',
          phoneme1: e.phoneme1,
          phoneme2: e.phoneme2,
          position: e.position,
          min_sonorant_diff: e.minSonorantDiff,
        };
      case 'contrastive_multopp':
        return {
          type: 'contrastive_multopp',
          substitute: e.substitute,
          targets: e.targets,
          n_targets: e.nTargets,
          position: e.position,
        };
    }
  });
}

If compileConstraints was previously aggregating multiple exclude StoreEntries into a single exclude Constraint with combined phonemes[] (a v6 optimization), preserve that aggregation pattern for exclude and include only. The contrastive and bound types are 1:1.

  • [ ] Step 5: Run the tests

Run: cd packages/web/frontend && npx vitest run src/lib/constraintCompiler.test.ts Expected: all tests pass (existing + 5 new).

  • [ ] Step 6: Update existing tests if any reference the old shape

If any pre-existing test expected min / max on bound (vs. min_value / max_value) or contrastive (vs. contrastive_minpair), update them — this is a deliberate breaking change to match the server schema. Re-run tests.

  • [ ] Step 7: Commit

Stage Tasks 7-10 together since they're a coherent type-shape rewrite that broke and re-built the type contracts:

git add packages/web/frontend/src/types/governance.ts packages/web/frontend/src/lib/generationApi.ts packages/web/frontend/src/store/constraintStore.ts packages/web/frontend/src/lib/constraintCompiler.ts packages/web/frontend/src/lib/constraintCompiler.test.ts
git commit -m "$(cat <<'EOF'
PHON-110: types + API client + store + compiler for 7-type constraints

- governance.ts: add SentenceCandidate / ParagraphCandidate / AxisScores +
  Generate*Request/Response. Replace 5-type Constraint union with 7-type
  to match server schemas.py (3 contrastive variants + bound_boost). Drop
  v6 RichToken / GenerationEvent / SingleGenerationResponse types.
- generationApi.ts: drop SSE parsing + preflight; add generateSentences /
  generateParagraphs Promise functions; preserve useServerStatus.
- constraintStore.ts: add mode / spec / band / axisWeights / topK +
  paragraph options; expand StoreEntry to 3 contrastive variants.
- constraintCompiler.ts: emit min_value/max_value (not min/max) and
  3 contrastive Constraint types; tests updated.

Tasks 11-14 fix the type-error fallout in the GovernedGenerationTool UI.
EOF
)"

Task 11: Composer UI — chip variants + new top-of-panel controls

Files: - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/ContrastiveSection.tsx - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/PsycholinguisticsSection.tsx - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/constraintChips.ts - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx

Why: Contrastive composer needs three chip variants. Bound/bound_boost chips need to expose min_value/max_value (cosmetic). Tool needs new top-of-panel controls: mode, spec, band, axis weights, top_k.

  • [ ] Step 1: Update the contrastive chip composer

Read ContrastiveSection.tsx. The current single-chip "contrastive" composer needs to become a small mode picker (radio or 3-tab) for minimal pair / maximal opposition / multiple opposition. Each branch renders the appropriate fields:

  • minpair: phoneme1, phoneme2, position
  • maxopp: phoneme1, phoneme2, position, min_sonorant_diff slider (0.0-1.0, default 0.5)
  • multopp: substitute, targets[] (chip multi-select), n_targets number input, position

Each "Add" button creates a StoreEntry of the corresponding contrastive_minpair / contrastive_maxopp / contrastive_multopp shape and appends to the constraint store. Use existing chip rendering for added entries.

  • [ ] Step 2: Update constraintChips.ts chipLabel() function

Find the existing chipLabel(entry: StoreEntry): string function. Add cases for the three contrastive variants and bound_boost:

case 'contrastive_minpair':
  return `min: /${entry.phoneme1}/ vs /${entry.phoneme2}/${entry.position !== 'any' ? ` (${entry.position})` : ''}`;
case 'contrastive_maxopp':
  return `max: /${entry.phoneme1}/ vs /${entry.phoneme2}/ Δson≥${entry.minSonorantDiff}`;
case 'contrastive_multopp':
  return `mult: /${entry.substitute}/ → {${entry.targets.join(',')}} (n=${entry.nTargets})`;
case 'bound_boost':
  return `boost: ${entry.norm} ${entry.direction}=${entry.value}`;

Drop the old 'contrastive' case.

  • [ ] Step 3: Add new top-of-panel controls to index.tsx

Replace the existing prompt text input + "Generate" button section with:

import { ToggleButtonGroup, ToggleButton, Slider, Box, TextField, Stack, Button } from '@mui/material';
import { useConstraintStore } from '../../../store/constraintStore';

// Inside the GovernedGenerationTool component body:

const mode = useConstraintStore((s) => s.mode);
const setMode = useConstraintStore((s) => s.setMode);
const spec = useConstraintStore((s) => s.spec);
const setSpec = useConstraintStore((s) => s.setSpec);
const band = useConstraintStore((s) => s.band);
const setBand = useConstraintStore((s) => s.setBand);
const axisWeights = useConstraintStore((s) => s.axisWeights);
const setAxisWeights = useConstraintStore((s) => s.setAxisWeights);
const topK = useConstraintStore((s) => s.topK);
const setTopK = useConstraintStore((s) => s.setTopK);

// In the JSX, above the constraint chip composer:
<Stack spacing={2}>
  <ToggleButtonGroup
    exclusive
    value={mode}
    onChange={(_, v) => v && setMode(v)}
    size="small"
  >
    <ToggleButton value="sentences">Sentences</ToggleButton>
    <ToggleButton value="paragraphs">Paragraphs</ToggleButton>
  </ToggleButtonGroup>

  <TextField
    label="Spec (target words, space-separated)"
    value={spec}
    onChange={(e) => setSpec(e.target.value)}
    placeholder="ball cup spoon"
    fullWidth size="small"
  />

  <TextField
    label="Frequency band"
    value={band}
    onChange={(e) => setBand(e.target.value)}
    select SelectProps={{ native: true }}
    size="small"
  >
    {['b1', 'b2', 'b3', 'b4', 'b5'].map((b) => <option key={b} value={b}>{b}</option>)}
  </TextField>

  <Box>
    <Typography variant="caption">Top K (candidate count)</Typography>
    <Slider value={topK} onChange={(_, v) => setTopK(v as number)} min={1} max={20} step={1} valueLabelDisplay="auto" />
  </Box>

  <AxisWeightSliders weights={axisWeights} onChange={setAxisWeights} />
</Stack>

AxisWeightSliders is a small new component — write it inline in the same file or in a sibling file. It renders four sliders (naturalness, grammaticality, age_appropriate, coherence), each 0-1 step 0.05, and on change emits a sum-normalized object so the four always sum to ~1:

function AxisWeightSliders({ weights, onChange }: { weights: AxisWeights; onChange: (w: AxisWeights) => void }) {
  const labels: Array<[keyof AxisWeights, string]> = [
    ['naturalness', 'Naturalness'],
    ['grammaticality', 'Grammaticality'],
    ['age_appropriate', 'Age-appropriate'],
    ['coherence', 'Coherence'],
  ];
  return (
    <Stack spacing={1}>
      <Typography variant="caption">Axis weights</Typography>
      {labels.map(([key, label]) => (
        <Box key={key}>
          <Typography variant="caption">{label}: {weights[key].toFixed(2)}</Typography>
          <Slider
            value={weights[key]}
            onChange={(_, v) => {
              const next = { ...weights, [key]: v as number };
              const sum = Object.values(next).reduce((a, b) => a + b, 0);
              const normalized = sum > 0
                ? Object.fromEntries(Object.entries(next).map(([k, val]) => [k, val / sum])) as AxisWeights
                : next;
              onChange(normalized);
            }}
            min={0} max={1} step={0.05} size="small"
          />
        </Box>
      ))}
    </Stack>
  );
}

For paragraph mode, render extra controls below (n_sentences, per_sentence_top_k, n_subject_seeds, discourse_subject text, use_pronoun_coref switch) wrapped in {mode === 'paragraphs' && (<>...</>)}.

  • [ ] Step 4: Replace the "Generate" button handler

The existing handler calls generateContent(prompt, constraints, callbacks). Replace with:

const handleGenerate = useCallback(async () => {
  setLoading(true);
  setError(null);
  const constraints = compileConstraints(useConstraintStore.getState().entries);
  const baseReq = { spec, band, constraints, axis_weights: axisWeights, top_k: topK };
  try {
    if (mode === 'sentences') {
      const res = await generateSentences(baseReq);
      setSentenceResults(res.candidates);
      setParagraphResults([]);
    } else {
      const res = await generateParagraphs({
        ...baseReq,
        n_sentences: nSentences,
        per_sentence_top_k: perSentenceTopK,
        n_subject_seeds: nSubjectSeeds,
        discourse_subject: discourseSubject || undefined,
        use_pronoun_coref: usePronounCoref,
      });
      setParagraphResults(res.paragraphs);
      setSentenceResults([]);
    }
  } catch (err) {
    setError((err as Error).message);
  } finally {
    setLoading(false);
  }
}, [mode, spec, band, axisWeights, topK, nSentences, perSentenceTopK, nSubjectSeeds, discourseSubject, usePronounCoref]);

sentenceResults and paragraphResults are new local React state replacing results: GenerationResult[]:

const [sentenceResults, setSentenceResults] = useState<SentenceCandidate[]>([]);
const [paragraphResults, setParagraphResults] = useState<ParagraphCandidate[]>([]);

The skeleton-state machinery (skeletonReducer, SkeletonOutputCard) is dead code post-rewrite; remove its imports + usage from index.tsx. Files themselves are deleted in Task 14.

  • [ ] Step 5: Type-check the tool subtree

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | grep "GovernedGenerationTool"

Expect remaining errors in OutputFeed.tsx, OutputCard.tsx, TokenDisplay.tsx — those are addressed in Tasks 12 + 14.

Do not commit yet — continue to Task 12.


Task 12: Output rendering — OutputFeed + OutputCard + AxisBars for candidates

Files: - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputFeed.tsx - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/OutputCard.tsx - Create: packages/web/frontend/src/components/tools/GovernedGenerationTool/AxisBars.tsx

Why: Output is now SentenceCandidate[] (or ParagraphCandidate[] in paragraph mode) instead of GenerationResult[]. New per-axis bars + composite badge + collapsible details.

  • [ ] Step 1: Create AxisBars.tsx

Path: packages/web/frontend/src/components/tools/GovernedGenerationTool/AxisBars.tsx. Content:

import { Box, LinearProgress, Stack, Typography } from '@mui/material';
import type { AxisScores } from '../../../types/governance';

const AXIS_LABELS: Record<keyof AxisScores, string> = {
  naturalness: 'Natural',
  grammaticality: 'Grammar',
  age_appropriate: 'Age-app',
  coherence: 'Cohere',
};

export function AxisBars({ scores }: { scores: AxisScores }) {
  return (
    <Stack spacing={0.5}>
      {(Object.keys(AXIS_LABELS) as Array<keyof AxisScores>).map((axis) => {
        const v = scores[axis];
        const pct = Math.round(v * 100);
        return (
          <Box key={axis} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
            <Typography variant="caption" sx={{ width: 56, fontFamily: 'monospace' }}>
              {AXIS_LABELS[axis]}
            </Typography>
            <Box sx={{ flex: 1 }}>
              <LinearProgress variant="determinate" value={pct * 100 / 100} />
            </Box>
            <Typography variant="caption" sx={{ width: 28, textAlign: 'right', fontFamily: 'monospace' }}>
              {v.toFixed(2)}
            </Typography>
          </Box>
        );
      })}
    </Stack>
  );
}
  • [ ] Step 2: Rewrite OutputCard.tsx for SentenceCandidate

Replace the file body with:

import { useState } from 'react';
import { Card, CardContent, Stack, Typography, Box, Chip, Collapse, IconButton } from '@mui/material';
import { ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from '@mui/icons-material';
import type { SentenceCandidate } from '../../../types/governance';
import { AxisBars } from './AxisBars';

export interface OutputCardProps {
  candidate: SentenceCandidate;
  selected: boolean;
  onToggleSelect: () => void;
}

function compositeColor(score: number): 'success' | 'warning' | 'error' | 'default' {
  if (score >= 0.75) return 'success';
  if (score >= 0.5) return 'warning';
  if (score < 0.25) return 'error';
  return 'default';
}

export default function OutputCard({ candidate, selected, onToggleSelect }: OutputCardProps) {
  const [expanded, setExpanded] = useState(false);
  return (
    <Card variant="outlined" sx={selected ? { borderColor: 'primary.main' } : undefined}>
      <CardContent sx={{ pb: '8px !important' }}>
        <Stack direction="row" spacing={1} alignItems="flex-start">
          <Typography variant="body1" sx={{ flex: 1, cursor: 'pointer' }} onClick={onToggleSelect}>
            {candidate.sentence}
          </Typography>
          <Chip
            label={candidate.composite_score.toFixed(2)}
            color={compositeColor(candidate.composite_score)}
            size="small"
          />
          <IconButton size="small" onClick={() => setExpanded((e) => !e)}>
            {expanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
          </IconButton>
        </Stack>
        <Box sx={{ mt: 1 }}>
          <AxisBars scores={candidate.axis_scores} />
        </Box>
        <Collapse in={expanded}>
          <Box sx={{ mt: 1, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
            <Typography variant="caption" component="div">verb: <code>{candidate.verb}</code></Typography>
            <Typography variant="caption" component="div">skeleton: <code>{candidate.skeleton}</code></Typography>
            <Typography variant="caption" component="div">
              fillers: {Object.entries(candidate.fillers).map(([role, w]) => `${role}=${w}`).join(', ')}
            </Typography>
            <Typography variant="caption" component="div">
              feature_distance: {candidate.feature_distance.toFixed(3)}  sonorant_diff: {candidate.sonorant_diff.toFixed(3)}  ppmi_total: {candidate.ppmi_total.toFixed(2)}
            </Typography>
          </Box>
        </Collapse>
      </CardContent>
    </Card>
  );
}
  • [ ] Step 3: Rewrite OutputFeed.tsx for both candidate kinds

Replace the file body. Key shape: it accepts both sentenceResults: SentenceCandidate[] and paragraphResults: ParagraphCandidate[] plus the current mode. It renders only the relevant array. Selection state stays as a Set<number> keyed by index. Export logic is rewritten for the new candidate fields.

import { useState, useCallback } from 'react';
import { Box, Stack, Typography, Button, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import { Download as DownloadIcon, SelectAll as SelectAllIcon, Clear as ClearIcon, Description as TextIcon, TableChart as CsvIcon } from '@mui/icons-material';
import type { SentenceCandidate, ParagraphCandidate, GenerationMode } from '../../../types/governance';
import OutputCard from './OutputCard';
import { AxisBars } from './AxisBars';

function downloadFile(content: string, filename: string, mime: string) {
  const blob = new Blob([content], { type: mime });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

function exportSentencesTxt(items: SentenceCandidate[]) {
  downloadFile(items.map((c) => c.sentence).join('\n'), 'governed_generation.txt', 'text/plain');
}
function exportSentencesCsv(items: SentenceCandidate[]) {
  const headers = ['sentence', 'composite_score', 'naturalness', 'grammaticality', 'age_appropriate', 'coherence', 'verb', 'skeleton'];
  const rows = items.map((c) => [
    `"${c.sentence.replace(/"/g, '""')}"`,
    c.composite_score.toFixed(4),
    c.axis_scores.naturalness.toFixed(4),
    c.axis_scores.grammaticality.toFixed(4),
    c.axis_scores.age_appropriate.toFixed(4),
    c.axis_scores.coherence.toFixed(4),
    c.verb,
    c.skeleton,
  ]);
  const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
  downloadFile(csv, 'governed_generation.csv', 'text/csv');
}

export interface OutputFeedProps {
  mode: GenerationMode;
  sentenceResults: SentenceCandidate[];
  paragraphResults: ParagraphCandidate[];
}

export default function OutputFeed({ mode, sentenceResults, paragraphResults }: OutputFeedProps) {
  const [selected, setSelected] = useState<Set<number>>(new Set());
  const [exportAnchor, setExportAnchor] = useState<HTMLElement | null>(null);

  const toggleSelect = useCallback((idx: number) => {
    setSelected((s) => {
      const next = new Set(s);
      next.has(idx) ? next.delete(idx) : next.add(idx);
      return next;
    });
  }, []);

  if (mode === 'sentences') {
    const exportItems = selected.size ? sentenceResults.filter((_, i) => selected.has(i)) : sentenceResults;
    return (
      <Stack spacing={1}>
        <Stack direction="row" spacing={1} alignItems="center">
          <Typography variant="overline" sx={{ flex: 1 }}>
            {sentenceResults.length} candidate{sentenceResults.length === 1 ? '' : 's'}
            {selected.size ? ` (${selected.size} selected)` : ''}
          </Typography>
          <Button size="small" startIcon={<SelectAllIcon />} onClick={() => setSelected(new Set(sentenceResults.map((_, i) => i)))}>All</Button>
          <Button size="small" startIcon={<ClearIcon />} onClick={() => setSelected(new Set())} disabled={!selected.size}>Clear</Button>
          <Button size="small" startIcon={<DownloadIcon />} onClick={(e) => setExportAnchor(e.currentTarget)} disabled={!sentenceResults.length}>Export</Button>
          <Menu anchorEl={exportAnchor} open={Boolean(exportAnchor)} onClose={() => setExportAnchor(null)}>
            <MenuItem onClick={() => { exportSentencesTxt(exportItems); setExportAnchor(null); }}>
              <ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
              <ListItemText>.txt</ListItemText>
            </MenuItem>
            <MenuItem onClick={() => { exportSentencesCsv(exportItems); setExportAnchor(null); }}>
              <ListItemIcon><CsvIcon fontSize="small" /></ListItemIcon>
              <ListItemText>.csv</ListItemText>
            </MenuItem>
          </Menu>
        </Stack>
        {sentenceResults.map((c, i) => (
          <OutputCard key={i} candidate={c} selected={selected.has(i)} onToggleSelect={() => toggleSelect(i)} />
        ))}
      </Stack>
    );
  }

  // paragraph mode
  return (
    <Stack spacing={2}>
      {paragraphResults.map((p, pi) => (
        <Box key={pi} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
          <Typography variant="overline">discourse subject: {p.discourse_subject}</Typography>
          <Box sx={{ my: 1 }}><AxisBars scores={p.axis_scores} /></Box>
          <Stack spacing={1}>
            {p.sentences.map((s, si) => (
              <OutputCard key={si} candidate={s} selected={false} onToggleSelect={() => {}} />
            ))}
          </Stack>
        </Box>
      ))}
    </Stack>
  );
}

Paragraph-mode export is left for a follow-up; the empty handler is the minimal viable behavior. (Paragraph CSV export is covered as a punch-list item if PHON-90 audit surfaces it.)

  • [ ] Step 4: Update index.tsx to pass the new props

In GovernedGenerationTool/index.tsx, replace the existing <OutputFeed results={results} ... /> invocation with:

<OutputFeed
  mode={mode}
  sentenceResults={sentenceResults}
  paragraphResults={paragraphResults}
/>
  • [ ] Step 5: Type-check

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | grep "GovernedGenerationTool" Expected: only TokenDisplay.tsx + skeletonReducer.ts errors remain (Task 14 deletes them).

Do not commit yet — Task 13 lands the layout, Task 14 deletes dead files.


Task 13: Tool layout — server-status pill + cold-start banner + 2-column body

Files: - Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx

Why: Header needs the server-status pill (idle / warming / ready) and a first-time-this-session cold-start banner ("First request takes ~25s").

  • [ ] Step 1: Add server-status pill

In index.tsx, add at the top of the component:

import { useServerStatus } from '../../../lib/generationApi';

const { status, hasFetched } = useServerStatus(5000);

const statusPillProps = !hasFetched
  ? { color: 'default' as const, label: 'checking…' }
  : status === 'warm'
  ? { color: 'success' as const, label: 'ready' }
  : status === 'down'
  ? { color: 'error' as const, label: 'unreachable' }
  : { color: 'warning' as const, label: 'warming' };

status here is ServerStatus. Verify the actual type — if ServerStatus is a string union ('warm' | 'down'), the above branches cover it.

  • [ ] Step 2: Render the pill in the header area

Replace any existing header content with:

<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
  <Typography variant="h6" sx={{ flex: 1 }}>Governed Generation</Typography>
  <Chip {...statusPillProps} size="small" />
</Stack>
  • [ ] Step 3: Add a one-shot cold-start banner
import { Alert } from '@mui/material';

const [bannerDismissed, setBannerDismissed] = useState(false);
const showBanner = !bannerDismissed && (!hasFetched || status !== 'warm');

{showBanner && (
  <Alert severity="info" onClose={() => setBannerDismissed(true)} sx={{ mb: 2 }}>
    First request takes about 25 seconds while the generation backend warms.
    Subsequent requests respond in 1-2 seconds.
  </Alert>
)}
  • [ ] Step 4: Confirm two-column layout

Verify the existing layout splits constraint composer (left) vs. output feed (right). If it doesn't, wrap the body in:

<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
  <Box>{/* composer panel */}</Box>
  <Box>{/* output panel — OutputFeed */}</Box>
</Box>

Adjust to the existing layout primitives (likely there's already a Stack or Grid in place).

  • [ ] Step 5: Type-check

Run: cd packages/web/frontend && npx tsc --noEmit 2>&1 | head -20 Expected: errors only in TokenDisplay.tsx and other files Task 14 deletes.

Do not commit yet — Task 14 finishes the rewrite.


Task 14: Delete dead v6 files + final verification

Files: - Delete: packages/web/frontend/src/components/tools/GovernedGenerationTool/TokenDisplay.tsx - Delete: packages/web/frontend/src/components/tools/GovernedGenerationTool/SkeletonOutputCard.tsx - Delete: packages/web/frontend/src/components/tools/GovernedGenerationTool/skeletonReducer.ts

Why: TokenDisplay rendered per-token compliance richtext (gone); SkeletonOutputCard + skeletonReducer powered streaming-skeleton placeholders (gone). All three are now unreferenced after Tasks 11-13.

  • [ ] Step 1: Verify zero remaining references

Run:

cd /Users/jneumann/Repos/PhonoLex/packages/web/frontend
grep -rn "TokenDisplay\|SkeletonOutputCard\|skeletonReducer\|RichTokenText\|TokenDetailPopover" src/ \
  --include="*.ts" --include="*.tsx"

Expected: no matches. Any matches indicate Task 11 or 13 missed an import — fix the consumer first.

  • [ ] Step 2: Delete the files
rm packages/web/frontend/src/components/tools/GovernedGenerationTool/TokenDisplay.tsx
rm packages/web/frontend/src/components/tools/GovernedGenerationTool/SkeletonOutputCard.tsx
rm packages/web/frontend/src/components/tools/GovernedGenerationTool/skeletonReducer.ts
  • [ ] Step 3: Type-check the entire frontend

Run: cd packages/web/frontend && npx tsc --noEmit Expected: no errors. If errors persist, they're in files Tasks 11-13 missed — fix them before committing.

  • [ ] Step 4: Run the existing Vitest suite

Run: cd packages/web/frontend && npx vitest run Expected: all tests pass (the constraintCompiler tests added in Task 10 + any pre-existing tests).

  • [ ] Step 5: Commit
git add -A packages/web/frontend/src/components/tools/GovernedGenerationTool/ packages/web/frontend/src/components/tools/GovernedGenerationTool/AxisBars.tsx
git commit -m "$(cat <<'EOF'
PHON-110: GovernedGenerationTool UI rewrite for top-K candidates

- Composer: contrastive section expanded to 3 variants (minpair, maxopp,
  multopp); chip labels updated; new top-of-panel controls (mode toggle,
  spec, band, top_k slider, axis-weight sliders).
- Output: OutputCard renders SentenceCandidate with composite score badge
  + AxisBars + collapsible details (verb, skeleton, fillers, distances).
  OutputFeed handles both sentence and paragraph modes; CSV export
  rewritten for the new candidate shape.
- Layout: header gains server-status pill (idle/warming/ready) + one-shot
  cold-start banner. Body is 2-column composer/output.
- Deleted: TokenDisplay.tsx, SkeletonOutputCard.tsx, skeletonReducer.ts —
  per-token richtext + streaming skeletons are not part of the CSP shape.
EOF
)"

Task 15: End-to-end manual smoke test

Files: none modified (verification only).

Why: Confirm the rewrite works against a live backend before declaring PHON-110 done.

  • [ ] Step 1: Start the generation server

In one terminal:

cd /Users/jneumann/Repos/PhonoLex
uv run uvicorn packages.generation.server.main:app --host 0.0.0.0 --port 8000

Wait for [startup] ready. (~25s).

  • [ ] Step 2: Start the frontend dev server

In a second terminal:

cd /Users/jneumann/Repos/PhonoLex/packages/web/frontend
npm run dev

Note the URL (typically http://localhost:5173).

  • [ ] Step 3: Open the Governed Generation tool in a browser

Verify in this order: 1. Server-status pill shows ready (after the FastAPI server is up). 2. Cold-start banner is visible on first load; clicking the close button hides it. 3. Composer renders with mode toggle, spec field, band selector, top_k slider, 4 axis-weight sliders. 4. Constraint chips composer still works for exclude/include/bound. 5. Contrastive section shows 3 sub-variants (minpair / maxopp / multopp) with their respective fields. 6. Click "Generate" with a minimal config (e.g., spec="cat dog", band="b3", no constraints, top_k=4). Wait ~1-2s. 7. Output panel shows top 4 sentence candidates, each with composite badge + 4 axis bars + collapsible details. 8. Selection + Export → .csv works. 9. Switch mode to Paragraphs; Generate; verify nested paragraph cards render with discourse-subject + per-paragraph axis bars + nested sentence cards.

  • [ ] Step 4: Document any findings

If anything misrenders or 500s, fix in additional commits (file under PHON-110 still). If everything works, no commit needed for this task.

  • [ ] Step 5: Stop both servers

Per the port-management memory: kill stale processes rather than hopping ports. Use lsof -i :8000 and lsof -i :5173 to identify and kill <pid> if needed.


Workstream 5: PHON-90 UI/UX audit

Task 16: Audit the six tools for v5.2 data-substrate coverage

Files: none yet (this task may produce zero or more downstream commits).

Why: PHON-88's 4-table split exposed ~98 new columns (PHON-72/73/76/81-87 norms). Most should already auto-render via properties.ts codegen; this task verifies that.

  • [ ] Step 1: Generate the property inventory

Run:

cd /Users/jneumann/Repos/PhonoLex
python -c "
import json
from pathlib import Path
import sys
sys.path.insert(0, 'packages/web/workers/scripts')
from config import PROPERTIES
print(json.dumps([(p.column, p.label, p.kind, p.filterable) for p in PROPERTIES], indent=2))
" 2>&1 | head -80

This produces the canonical list of properties (column, label, kind, filterable). Save to a scratch file for reference.

  • [ ] Step 2: Verify properties.ts matches

Run: head -80 packages/web/workers/src/config/properties.ts

Confirm the auto-generated TypeScript metadata aligns with the Python source. If there's a build step to regenerate, run it.

  • [ ] Step 3: Walk each tool

For each tool below, open it in the running frontend (Task 15's dev server), check the items, and either confirm "✓ wired" or capture the gap:

Custom Word Lists (CustomWordListsTool or similar): - [ ] All properties from PROPERTIES with filterable: true appear as filter checkboxes. - [ ] Filtering by a new norm (e.g., concreteness, BOI, iconicity) returns sensible results. - [ ] Frequency-band columns from PHON-85/86/87 (e.g., freq_b1_school etc.) are filterable.

Text Analysis: - [ ] Per-word stats include the new norms. - [ ] Aggregate percentile readouts cover the new norms.

Sound Similarity: - [ ] Tool still works; not affected by the new columns. (Sanity only.)

Lookup: - [ ] Word-detail panel renders the new norm fields (concreteness, valence, arousal, familiarity, BOI, iconicity, semantic_diversity, etc.). - [ ] PHON-88's frequency-band columns from the joined tables (word_freq_bands) are rendered if relevant.

Contrastive Sets: skip (PHON-111 covers).

Governed Generation: skip (PHON-110 covers).

  • [ ] Step 4: For each gap, file or fix

If any tool is missing a property: 1. If it's a properties.ts codegen miss → update packages/web/workers/scripts/config.py PROPERTIES list, regenerate, commit. 2. If it's a frontend rendering miss → add the missing field to the tool's display logic, commit. 3. If it's a worker route miss → update the route + commit.

Each fix is its own commit:

git commit -m "PHON-90: surface <property> in <tool>"

If no gaps: this task produces zero commits and we move on.

  • [ ] Step 5: Capture findings

If gaps were found and fixed, summarize in a final scratch note. If no gaps, note "PHON-90 audit clean — no commits produced".


Workstream 6: PHON-111 Contrastive Sets parity

Task 17: Cache learned phoneme vectors + L2 distance helper in worker

Files: - Create: packages/web/workers/src/lib/phonemeVectors.ts - Create: packages/web/workers/src/__tests__/phonemeVectors.test.ts

Why: Replace discrete distinctive-feature distance with continuous L2 over learned 26-d posterior vectors from packages/features/outputs/vectors.csv. Vectors must be cached in worker isolate at cold start.

  • [ ] Step 1: Inspect the source CSV

Run: head -3 packages/features/outputs/vectors.csv

Confirm: header is ipa,syllabic,consonantal,sonorant,... (27 columns total — ipa + 26 features). Phoneme rows for ~45 IPA segments.

  • [ ] Step 2: Decide ship-shape — embed-as-JSON vs build-step

Simplest: convert vectors.csv → JSON object Record<string, number[]> and inline-import into the worker bundle. Total size ≈ 45 phonemes × 26 floats × 12 bytes ≈ 14KB; trivial.

Run from repo root:

python -c "
import csv, json
with open('packages/features/outputs/vectors.csv') as f:
    rows = list(csv.reader(f))
header = rows[0][1:]  # 26 features
data = {row[0]: [float(x) for x in row[1:]] for row in rows[1:]}
with open('packages/web/workers/src/lib/phonemeVectors.json', 'w') as f:
    json.dump({'features': header, 'vectors': data}, f, indent=2)
print(f'wrote {len(data)} phonemes × {len(header)} dims')
"

This generates packages/web/workers/src/lib/phonemeVectors.json.

  • [ ] Step 3: Write the failing test

Path: packages/web/workers/src/__tests__/phonemeVectors.test.ts. Content:

import { describe, it, expect } from 'vitest';
import { l2Distance, getVector, phonemeFeatures } from '../lib/phonemeVectors';

describe('phoneme vectors', () => {
  it('exposes the 26 feature names', () => {
    expect(phonemeFeatures).toHaveLength(26);
    expect(phonemeFeatures[0]).toBe('syllabic');
  });

  it('returns a 26-d vector for a known phoneme (p)', () => {
    const v = getVector('p');
    expect(v).toBeDefined();
    expect(v).toHaveLength(26);
  });

  it('returns undefined for an unknown phoneme', () => {
    expect(getVector('§')).toBeUndefined();
  });

  it('l2Distance is 0 for identical phonemes', () => {
    expect(l2Distance('p', 'p')).toBe(0);
  });

  it('l2Distance is positive for distinct phonemes', () => {
    expect(l2Distance('p', 'b')).toBeGreaterThan(0);
  });

  it('l2Distance is symmetric', () => {
    const d1 = l2Distance('p', 't');
    const d2 = l2Distance('t', 'p');
    expect(d1).toBeCloseTo(d2, 10);
  });

  it('l2Distance returns null for unknown phonemes', () => {
    expect(l2Distance('p', '§')).toBeNull();
  });
});
  • [ ] Step 4: Implement phonemeVectors.ts

Path: packages/web/workers/src/lib/phonemeVectors.ts. Content:

/**
 * Cached learned phoneme feature vectors (26-d posterior from
 * packages/features Bayesian inference). Used by the Contrastive Sets
 * route (PHON-111) to compute continuous L2 distance, replacing the
 * discrete distinctive-feature count.
 */
import vectorData from './phonemeVectors.json';

const RAW = vectorData as { features: string[]; vectors: Record<string, number[]> };

export const phonemeFeatures: readonly string[] = RAW.features;

export function getVector(ipa: string): number[] | undefined {
  return RAW.vectors[ipa];
}

export function l2Distance(p1: string, p2: string): number | null {
  const v1 = RAW.vectors[p1];
  const v2 = RAW.vectors[p2];
  if (!v1 || !v2) return null;
  let sum = 0;
  for (let i = 0; i < v1.length; i++) {
    const d = v1[i] - v2[i];
    sum += d * d;
  }
  return Math.sqrt(sum);
}
  • [ ] Step 5: Run the tests

Run: cd packages/web/workers && npx vitest run src/__tests__/phonemeVectors.test.ts Expected: all 7 tests pass.

  • [ ] Step 6: Commit
git add packages/web/workers/src/lib/phonemeVectors.ts packages/web/workers/src/lib/phonemeVectors.json packages/web/workers/src/__tests__/phonemeVectors.test.ts
git commit -m "$(cat <<'EOF'
PHON-111: cache learned phoneme vectors + L2 distance helper

Bundles 45-phoneme × 26-d posterior vectors from packages/features as
inline JSON (~14KB), exposes l2Distance(p1, p2). Used by Contrastive
Sets route in the next commit to replace discrete-feature distance
with continuous L2.
EOF
)"

Task 18: Switch routes/contrastive.ts to continuous L2

Files: - Modify: packages/web/workers/src/routes/contrastive.ts - Add/update: tests as needed

Why: Bring the web app's contrastive distance into parity with CSP runtime (pairs.parquet's continuous feature_distance).

  • [ ] Step 1: Identify where discrete distance is used

Run: grep -n "countFeatureDiffs\|hasMajorClassDiff" packages/web/workers/src/routes/contrastive.ts

Note all callsites. Routes likely affected: - maximal-opposition pair scoring (uses countFeatureDiffs + hasMajorClassDiff) - multiple-opposition target ranking (uses feature distance to pick targets dissimilar to substitute)

  • [ ] Step 2: Replace the distance backend

Modify the affected handlers to use l2Distance(p1, p2) from ../lib/phonemeVectors instead of countFeatureDiffs(p1Features, p2Features). The hasMajorClassDiff function used a sonorant-difference filter; preserve its semantics by checking the sonorant feature index in the vector:

import { l2Distance, getVector, phonemeFeatures } from '../lib/phonemeVectors';

const SONORANT_IDX = phonemeFeatures.indexOf('sonorant');

function hasSonorantDiff(p1: string, p2: string, threshold = 0.5): boolean {
  const v1 = getVector(p1);
  const v2 = getVector(p2);
  if (!v1 || !v2) return false;
  return Math.abs(v1[SONORANT_IDX] - v2[SONORANT_IDX]) >= threshold;
}

Where the route formerly returned featureDiffs: number (integer count), it now returns featureDistance: number (continuous float). Update field names accordingly. If the frontend reads featureDiffs, that needs an update too — check ContrastiveInterventionTool.tsx.

  • [ ] Step 3: Update or remove countFeatureDiffs and hasMajorClassDiff

If no callers remain, delete both functions and the getPhonemeMap helper that loaded discrete features from D1. The discrete phonemes.features D1 column may still be used elsewhere — grep for JSON.parse(row.features) to confirm before deleting getPhonemeMap.

If getPhonemeMap is used by other routes, leave it alone — just remove its calls from contrastive.ts.

  • [ ] Step 4: Update the frontend ContrastiveInterventionTool.tsx

Run: grep -n "featureDiffs\|hasMajorClassDiff" packages/web/frontend/src/components/tools/ContrastiveInterventionTool.tsx

If matches exist, update field names + types to match the new continuous shape.

  • [ ] Step 5: Run worker tests

Run: cd packages/web/workers && npx vitest run Expected: all tests pass. Update fixtures if a test references the old featureDiffs integer.

  • [ ] Step 6: Smoke test in the running frontend

Restart the worker dev server (or reload), open Contrastive Sets tool. Verify that: - Maximal Opposition pair scoring still ranks pairs sensibly (e.g., /p/ vs /l/ should rank as "more contrastive" than /p/ vs /b/). - Distances render as floats (e.g., 1.42), not small integers (formerly 0-26).

  • [ ] Step 7: Commit
git add packages/web/workers/src/routes/contrastive.ts packages/web/frontend/src/components/tools/ContrastiveInterventionTool.tsx packages/web/workers/src/__tests__/
git commit -m "$(cat <<'EOF'
PHON-111: Contrastive Sets uses continuous L2 over learned vectors

Replaces discrete countFeatureDiffs (integer 0-26) with l2Distance()
over 26-d posterior vectors from packages/features. Brings web-app
contrastive scoring into parity with CSP's pairs.parquet (continuous
feature_distance from the same Bayesian-learned vectors).

Frontend ContrastiveInterventionTool updated to render the new
continuous values; tests rewritten against L2.
EOF
)"

Workstream 7: Jira hygiene

Task 19: Close stale tickets + backfill fixVersion = v5.2.0

Files: none (Jira API only).

Why: Memory says PHON-72/79/85/86/87 are done but Jira shows In Progress / Backlog; PHON-80 obsoleted by RunPod retirement; no tickets currently carry fixVersion = v5.2.0.

  • [ ] Step 1: Confirm the v5.2.0 fixVersion exists in Jira

Open https://neumannsworkshop.atlassian.net/projects/PHON?selectedItem=com.atlassian.jira.jira-projects-plugin:release-page → confirm a v5.2.0 version exists, or create one with start date = first PHON-72 commit and release date = TBD.

  • [ ] Step 2: Close stale tickets

For each of PHON-72, PHON-79, PHON-85, PHON-86, PHON-87: - Transition to Done. - Add comment summarizing what shipped + which PR (per memory: PHON-72 → PHON-88 PR #84; PHON-79 → ditto; PHON-85/86/87 → also via PHON-88). - Set fixVersion = v5.2.0.

For PHON-80: - Transition to Won't Do (or Closed). - Add comment: "RunPod retired in PHON-109 (2026-05-09); no longer applicable."

  • [ ] Step 3: Backfill fixVersion = v5.2.0 on shipped tickets

For each of: PHON-73, 76, 81, 82, 83, 84, 88, 92, 93, 94, 95, 96, 97, 98, 99, 100, 102, 106, 107, 109, 112, 113 — set fixVersion = v5.2.0. (PHON-72/79/85/86/87 already covered in Step 2.)

A bulk JQL update via the Atlassian MCP saves time:

project = PHON AND key in (PHON-73, PHON-76, ...) ORDER BY key

then iterate setting fixVersion.

  • [ ] Step 4: Sanity-check the release manifest

Query: project = PHON AND fixVersion = v5.2.0 ORDER BY key. Expected: the full list above (~26 tickets). If any are missing, backfill.

  • [ ] Step 5: Sync the memory pointer

Update memory/reference_jira_backlog.md if it carries stale ticket counts/states. If it doesn't exist or doesn't track these states, no action.

This task produces zero git commits.


Closeout

After Task 19, release/v5.2.0 is branch-complete: - git status clean. - npx tsc --noEmit green in both packages/web/frontend and packages/web/workers. - npx vitest run green in both packages. - Generation server starts cleanly: uv run uvicorn packages.generation.server.main:app produces [startup] ready. in ~25s. - Frontend Governed Generation tool generates top-K candidates end-to-end. - Docker image builds: docker build -f packages/generation/server/Dockerfile -t phonolex-generation:dev . - Jira project = PHON AND fixVersion = v5.2.0 returns the expected manifest.

Merge to develop/main and tag/release-notes are the next session's work.