Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ flowchart TB
| `packages/data/src/jsonl-reader.ts` | Generic JSONL file reader (returns typed array) |
| `packages/data/src/analytics.ts` | DuckDB JSONL-to-Parquet import, test run summaries, per-test queries, detail views |
| `packages/data/src/run-status.ts` | SCIL and ACIL analytics: history and run detail queries over Parquet |
| `packages/data/src/parquet-files.ts` | `parquetFile()` and `hasParquet()` helpers for checking which Parquet files exist before querying |
| `packages/data/src/re-eval-marker.ts` | Tracks re-evaluated run IDs via `.re-evaluated-runs.json` marker file |
| `packages/data/src/scil-split.ts` | Deterministic stratified train/test splitting with seeded PRNG |
| `packages/data/src/scil-prompt.ts` | Builds LLM improvement prompts from SCIL iteration results |
Expand Down Expand Up @@ -216,6 +217,22 @@ Three main query functions join across Parquet files:
- **`queryTestRunSummaries()`** — Aggregates per-test results into run-level pass/fail counts by eval
- **`queryTestRunDetails()`** — Returns detailed per-test summaries, individual expectation results, grouped LLM judge criteria, and output files for a single run

#### Missing Parquet files

Any Parquet file may be missing: the data directory may not exist yet, and `updateAllParquet()` skips any table with no source JSONL. DuckDB's `read_parquet` throws `IO Error: No files found that match the pattern` for a missing file, so every query checks with `hasParquet()` before reading:

| Missing file | List queries (`queryTestRunSummaries`, `queryPerTest`, `queryScilHistory`, `queryAcilHistory`) | Detail queries |
|---|---|---|
| `test-run` or `test-config` | Return `[]` | `queryTestRunDetails` throws `Test run not found: <id>` |
| `test-results` | Return rows with `all_expectations_passed: null` | `queryTestRunDetails` returns empty `expectations` and `llmJudgeGroups` |
| `scil-iteration` / `acil-iteration` | Return `[]` | Return `iterations: []` |
| `scil-summary` / `acil-summary` | — | Throw `SCIL run not found: <id>` / `ACIL run not found: <id>` |

Two caveats apply:

- A partial write that leaves `test-run.parquet` without `test-config.parquet` reads as "no data", not as an error.
- The first write of each Parquet file goes straight to its final path, with no temp file and rename. An interrupted first write can leave a corrupt file that passes `hasParquet()` and still makes the query throw.

All queries filter out `infrastructure-error` status rows when the `status` column exists in the Parquet schema (backward compatibility with older data).

SCIL-specific queries in `run-status.ts`:
Expand Down
25 changes: 15 additions & 10 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,11 @@ interface ScilSummaryRow {

#### Server Startup and Asset Embedding

The server entry (`packages/web/src/server/index.ts`) uses Yargs to parse `--port` and `--data-dir` CLI arguments. It registers six API routes and three static asset routes. The client build output (`dist/client/`) is embedded using Bun's `import ... with { type: 'file' }` syntax, which resolves to `$bunfs` paths in compiled standalone executables. A SPA fallback (`/*`) serves `index.html` for all unmatched paths, enabling client-side routing.
The server entry (`packages/web/src/server/index.ts`) uses Yargs to parse `--port` and `--data-dir` CLI arguments. It registers the API routes, a `jsonErrorHandler` via `app.onError` so unexpected errors reach the client as JSON, and the static asset routes. The client build output (`dist/client/`) is embedded using Bun's `import ... with { type: 'file' }` syntax, which resolves to `$bunfs` paths in compiled standalone executables. A SPA fallback (`/*`) serves `index.html` for all unmatched paths, enabling client-side routing.

#### Route Handler Pattern

All three route modules follow the same pattern: receive a Hono `Context` and a `dataDir` string, call the corresponding `@testdouble/skillwalker-data` query function, and return the result via `c.json()`. Error handling distinguishes "not found" errors (returned as 404 JSON) from unexpected errors (re-thrown). The SCIL routes additionally handle missing Parquet files by returning empty results or 404.
All three route modules follow the same pattern: receive a Hono `Context` and a `dataDir` string, call the corresponding `@testdouble/skillwalker-data` query function, and return the result via `c.json()`. Error handling distinguishes "not found" and `InvalidRunIdError` errors (returned as 404 JSON) from unexpected errors (re-thrown to `jsonErrorHandler`). Missing Parquet files need no route-level handling: the data layer returns empty results or throws "run not found".

```typescript
// packages/web/src/server/routes/test-runs.ts — typical handler pattern
Expand All @@ -223,7 +223,7 @@ export async function getTestRunById(c: Context, dataDir: string): Promise<Respo
const { summary, expectations, llmJudgeGroups, outputFiles } = await queryTestRunDetails(dataDir, runId)
return c.json({ summary, expectations, llmJudgeGroups, outputFiles })
} catch (err) {
if (err instanceof Error && err.message.startsWith('Test run not found:')) {
if (err instanceof InvalidRunIdError || (err instanceof Error && err.message.startsWith('Test run not found:'))) {
return c.json({ error: 'Not found' }, 404)
}
throw err
Expand Down Expand Up @@ -402,16 +402,17 @@ flowchart TB
|----------|---------------------|----------|
| Test run not found | `404` `{ error: "Not found" }` | Error message starts with `"Test run not found:"` |
| SCIL run not found | `404` `{ error: "Not found" }` | Error message starts with `"SCIL run not found:"` |
| Missing Parquet file (SCIL history) | `200` `{ runs: [] }` | Error message contains `"No such file or directory"`, returns empty array |
| Missing Parquet file (SCIL detail) | `404` `{ error: "Not found" }` | Error message contains `"No such file or directory"` |
| Unexpected error | Re-thrown (500) | Non-matching errors propagate to Hono's default error handler |
| Non-Error throwable | Re-thrown | String or other non-Error values bypass the `instanceof Error` check |
| ACIL run not found | `404` `{ error: "Not found" }` | Error message starts with `"ACIL run not found:"` |
| Malformed run ID | `404` `{ error: "Not found" }` | Detail routes catch `InvalidRunIdError` from the data layer |
| No data (missing or empty data directory, or missing Parquet files) | `200` `{ runs: [] }` / `{ rows: [] }` for lists; `404` for details | The data layer returns empty results or throws "run not found"; routes do no file checks of their own |
| Unexpected error | `500` `{ error: "Internal server error" }` | Non-matching errors are re-thrown to `jsonErrorHandler` (registered with `app.onError`), which logs them and returns JSON |
| Non-Error throwable | `500` JSON | String or other non-Error values bypass the `instanceof Error` checks and reach `jsonErrorHandler` |

### Frontend
| Scenario | Error Handling | Behavior |
|----------|----------------|----------|
| API fetch failure | Error state string | Displayed in a red-bordered error banner |
| Empty data | Empty state message | Displayed as centered gray text with usage instructions |
| API fetch failure | Error state string | Every page fetches through `fetchJson()` (`client/lib/fetch-json.ts`), which throws the server's `error` field for a non-OK response, or the status text when the body is not JSON. The message is displayed in a red-bordered error banner |
| Empty data | Empty state message | Displayed as centered gray text with usage instructions on the History, SCIL History, ACIL History, and Analytics pages |
| Loading | Loading state | Displays centered "Loading..." text |

## Configuration
Expand All @@ -425,9 +426,13 @@ flowchart TB

### Backend
- `packages/web/src/server/routes/test-runs.test.ts` — Tests `getTestRuns` and `getTestRunById` with mocked `skillwalker-data` query functions
- `packages/web/src/server/routes/scil.test.ts` — Tests `getScilHistory` and `getScilRunById` including missing Parquet file handling
- `packages/web/src/server/routes/scil.test.ts` / `acil.test.ts` — Test the SCIL and ACIL history and detail handlers, including 404 for malformed run IDs
- `packages/web/src/server/routes/error-handler.test.ts` — Tests that `jsonErrorHandler` logs the error and returns a JSON 500
- `packages/web/src/server/routes/analytics.test.ts` — Tests `getPerTestAnalytics` including eval filter behavior

### Frontend
- `packages/web/src/client/lib/fetch-json.test.ts` — Tests `fetchJson` with OK, non-OK JSON, and non-OK plain-text responses, using `vi.stubGlobal('fetch', ...)`

### Test Patterns
- All tests mock `@testdouble/skillwalker-data` at the module level using `vi.mock()` with inline factory functions
- A `makeMockContext()` factory creates mock Hono `Context` objects with configurable `param` and `query` accessors
Expand Down
161 changes: 141 additions & 20 deletions packages/data/src/analytics.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,33 +545,65 @@ describe('queryPerTest (JOIN edge cases)', () => {
expect(rows).toHaveLength(1)
expect(rows[0].all_expectations_passed).toBeNull()
})

it('throws DuckDB IO error when any parquet file is missing', async () => {
// dataDir exists but contains no parquet files
const dataDir = path.join(tmpDir, 'empty-analytics')
await mkdir(dataDir, { recursive: true })

await expect(queryPerTest(dataDir)).rejects.toThrow()
})
})

// ─── queryTestRunDetails — missing parquet ────────────────────────────────────

describe('queryTestRunDetails (missing parquet)', () => {
it('throws DuckDB IO error when test-results.parquet is missing but test-run.parquet exists', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await mkdir(dataDir, { recursive: true })
// ─── queries with missing parquet files ──────────────────────────────────────

describe('queries with no data', () => {
const runId = '20260101T100001'

for (const [label, makeDataDir] of [
['a data directory that does not exist', async () => path.join(tmpDir, 'missing-analytics')],
[
'an empty data directory',
async () => {
const dataDir = path.join(tmpDir, 'empty-analytics')
await mkdir(dataDir, { recursive: true })
return dataDir
},
],
] as const) {
describe(`given ${label}`, () => {
it('queryTestRunSummaries returns an empty list', async () => {
expect(await queryTestRunSummaries(await makeDataDir())).toEqual([])
})

it('queryPerTest returns an empty list', async () => {
expect(await queryPerTest(await makeDataDir())).toEqual([])
})

it('queryScilHistory returns an empty list', async () => {
expect(await queryScilHistory(await makeDataDir())).toEqual([])
})

it('queryAcilHistory returns an empty list', async () => {
expect(await queryAcilHistory(await makeDataDir())).toEqual([])
})

it('queryTestRunDetails rejects with not found', async () => {
await expect(queryTestRunDetails(await makeDataDir(), runId)).rejects.toThrow(`Test run not found: ${runId}`)
})

it('queryScilRunDetails rejects with not found', async () => {
await expect(queryScilRunDetails(await makeDataDir(), runId)).rejects.toThrow(`SCIL run not found: ${runId}`)
})

it('queryAcilRunDetails rejects with not found', async () => {
await expect(queryAcilRunDetails(await makeDataDir(), runId)).rejects.toThrow(`ACIL run not found: ${runId}`)
})
})
}
})

// Import only test-run and test-config — skip test-results
describe('queries with partial test-run parquet files', () => {
async function importRunAndConfigOnly(outputDir: string, dataDir: string): Promise<void> {
const runDir = path.join(outputDir, '20260101T100001')
await writeJsonl(path.join(runDir, 'test-config.jsonl'), [
makeConfigRecord({ testRunId: '20260101T100001', eval: 's', testName: 't' }),
])
await writeJsonl(path.join(runDir, 'test-run.jsonl'), [
makeRunResultRecord({ testRunId: '20260101T100001', eval: 's', testName: 't' }),
])
// Manually import only test-run and test-config
await importJsonlToParquet({
jsonlGlob: `${outputDir}/*/test-config.jsonl`,
parquetPath: path.join(dataDir, 'test-config.parquet'),
Expand All @@ -581,10 +613,99 @@ describe('queryTestRunDetails (missing parquet)', () => {
parquetPath: path.join(dataDir, 'test-run.parquet'),
filter: (obj) => (obj as Record<string, unknown>).type === 'result',
})
// test-results.parquet intentionally NOT created
}

describe('given test-run and test-config without test-results', () => {
it('queryTestRunSummaries returns the run with no passing tests', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await mkdir(dataDir, { recursive: true })
await importRunAndConfigOnly(outputDir, dataDir)

const runs = await queryTestRunSummaries(dataDir)

expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({ test_run_id: '20260101T100001', total_tests: 1, passed: 0, failed: 1 })
})

it('queryPerTest returns rows with null all_expectations_passed', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await mkdir(dataDir, { recursive: true })
await importRunAndConfigOnly(outputDir, dataDir)

const rows = await queryPerTest(dataDir)

expect(rows).toHaveLength(1)
expect(rows[0].all_expectations_passed).toBeNull()
})

it('queryTestRunDetails returns the summary with empty expectations', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await mkdir(dataDir, { recursive: true })
await importRunAndConfigOnly(outputDir, dataDir)

const details = await queryTestRunDetails(dataDir, '20260101T100001')

expect(details.summary).toHaveLength(1)
expect(details.summary[0].all_expectations_passed).toBeNull()
expect(details.expectations).toEqual([])
expect(details.llmJudgeGroups).toEqual([])
})
})

describe('given test-run without test-config', () => {
// A partial write that dropped test-config reads as "no data", not an error.
it('list queries return empty and details report not found', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await mkdir(dataDir, { recursive: true })
await importRunAndConfigOnly(outputDir, dataDir)
await rm(path.join(dataDir, 'test-config.parquet'))

expect(await queryTestRunSummaries(dataDir)).toEqual([])
expect(await queryPerTest(dataDir)).toEqual([])
await expect(queryTestRunDetails(dataDir, '20260101T100001')).rejects.toThrow(
'Test run not found: 20260101T100001',
)
})
})
})

describe('SCIL and ACIL details with a summary but no iterations', () => {
it('queryScilRunDetails returns the summary with empty iterations', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await writeScilRunFixture({
outputDir,
runId: '20260101T200001',
iterations: [makeScilIterationRecord({ test_run_id: '20260101T200001' })],
})
await rm(path.join(outputDir, '20260101T200001', 'scil-iteration.jsonl'))
await updateAllParquet({ outputDir, dataDir })

const details = await queryScilRunDetails(dataDir, '20260101T200001')

expect(details.summary.test_run_id).toBe('20260101T200001')
expect(details.iterations).toEqual([])
})

it('queryAcilRunDetails returns the summary with empty iterations', async () => {
const outputDir = path.join(tmpDir, 'output')
const dataDir = path.join(tmpDir, 'analytics')
await writeAcilRunFixture({
outputDir,
runId: '20260101T200001',
iterations: [makeAcilIterationRecord({ test_run_id: '20260101T200001' })],
})
await rm(path.join(outputDir, '20260101T200001', 'acil-iteration.jsonl'))
await updateAllParquet({ outputDir, dataDir })

const details = await queryAcilRunDetails(dataDir, '20260101T200001')

// Existence check passes (run is in test-run), but summary query fails on missing test-results
await expect(queryTestRunDetails(dataDir, '20260101T100001')).rejects.toThrow()
expect(details.summary.test_run_id).toBe('20260101T200001')
expect(details.iterations).toEqual([])
})
})

Expand Down
Loading
Loading