diff --git a/CLAUDE.md b/CLAUDE.md index 9ee679b..78e75fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,12 @@ Flow: `network/udpListener` → `telemetry/dispatcher` (parse) → `persistence/ | `src/telemetry/` | Binary parsing of the F1 24 protocol, one folder per packet type (`header`, `motion`, `lapData`, `carTelemetry`, `session`), shared `BufferCursor`, sizes and `assertBytesRead` in `shared/` | | `src/ws/` | WebSocket message envelope | | `src/persistence/` | SQLite schema and its migrations (`migrations.ts`), per-session in-memory context, decimation, lap completion detection, repositories (one per table); sample tables deduplicate on `(session_id, overall_frame_identifier)` — that field never resets or regresses within a session, including across a flashback, unlike session time or lap distance | -| `src/analysis/` | Pure functions, no I/O: corner detection, braking/throttle, delta, line deviation, tyre windows → `buildInsights` | +| `src/analysis/` | Pure functions, no I/O: track geometry and corner detection, braking/throttle, delta, line deviation, tyre windows → `buildInsights` | | `src/coaching/` | Static system prompt, user-message builder, the code's reading of the compared laps and the opportunities (opportunity rule and labels, direction phrases), prompt version, analysis resolver (staleness rule, one call per pair), Anthropic call with typed errors | | `src/http/` | Express app and routers (`sessionsRouter`, `analysisRouter`) | | `src/replay/` | Pure replay pacing (`computeDelayMs`) and orchestration (`runReplay`): speed, loop, absolute-deadline scheduling | | `src/tooling/` | Tests of the project's own tooling: the test of the empty-discovery guard | -| `tools/` | CLI scripts: recorder, analyze (insights, coach message, prompt version), a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | +| `tools/` | CLI scripts: recorder, analyze (insights, coach message, prompt version), `exportRecordingCut.ts` (regenerates `src/analysis/recordingCut.json` from the published recording, in a temporary database), a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | The REST and WebSocket payloads are a contract consumed by the frontend, which mirrors the types by hand. This repo owns that contract: any change to `src/ws/messageFormat.ts`, the routers or the repository row types must be flagged so the frontend's `src/types/` can be updated. @@ -55,14 +55,14 @@ The REST and WebSocket payloads are a contract consumed by the frontend, which m - `analysis/` stays pure and deterministic. The AI coach only ever receives the derived `LapInsights` JSON, never raw samples, plus the compared laps' official number, time and validity and the code's reading of the opportunities. - Repositories use prepared statements created at module load and map snake_case rows to camelCase objects. - Unknown values are stored as `NULL`, never invented (see `lapCompletionDetector`). -- Tests: flat `test()` calls, synthetic data from a `testFixtures.ts` per folder (`src/analysis/`, `src/replay/`), mostly pure functions. The handful of tests that need a real SQLite connection (sample repositories, the replay integration test) use `src/persistence/testDb.ts` to open an isolated, disposable database via `DB_PATH` — never `data/telemetry.db`. A new test file under `src/` is picked up by the `test` script with no edit. +- Tests: flat `test()` calls, synthetic data from a `testFixtures.ts` per folder (`src/analysis/`, `src/replay/`), mostly pure functions; `src/analysis/` also checks properties on real laps from the versioned cut `recordingCut.json`, with no database. The handful of tests that need a real SQLite connection (sample repositories, the replay integration test) use `src/persistence/testDb.ts` to open an isolated, disposable database via `DB_PATH` — never `data/telemetry.db`. A new test file under `src/` is picked up by the `test` script with no edit. - Commits: small, Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`, `ci:`), English subject, **no `Co-Authored-By` trailer**. - **Check names are a contract:** renaming a CI job (`typecheck`, `test`) requires updating the required checks of the ruleset on `main` in the same piece of work, and renaming `ci.yml` requires updating the README badge in the same piece of work. - **Actions:** first-party `actions/*` by major tag; any third-party action by full commit SHA with a trailing version comment. ## Rules -- Never commit secrets: `.env`, API keys, tokens. `.env`, `data/` and `recordings/` are gitignored. +- Never commit secrets: `.env`, API keys, tokens. `.env`, `data/` and `recordings/` are gitignored. One exception, on purpose: `src/analysis/recordingCut.json` is versioned. It is a cut of the published sample recording (laps 6–8, only the fields the engine reads), so it holds none of the packets the engine does not use, it is small (ceiling 660,000 bytes, enforced by a test) and it comes from data already public as a release asset. Regenerate it with `tools/exportRecordingCut.ts`, never by hand. - Run `npm test` and `npm run typecheck` (the same scripts the CI calls) before every commit; report any failure instead of committing around it. - Do not change packet layouts, field order or sizes in `src/telemetry/` without checking the official spec, *Data Output from F1 24* (v27.2x). - Documentation must describe only what exists in the code. Partial features are described as partial; no invented metrics or results. @@ -88,12 +88,16 @@ These are also listed under "Known limitations" in the README. When a limitation - The `max` replay speed overflows the UDP receive buffer by design: laps, times and sectors arrive intact, but a large share of the per-distance samples is lost. `1x` and `2x` do not lose samples. - `--speed=max` combined with `--loop` delivers practically no samples (measured: effectively zero) — the flood never lets up between passes. Use `1x` or `2x` for a continuous demo. - A looped replay of a recording whose own `overallFrameIdentifier` span is at or below `PASS_RESTART_THRESHOLD_FRAMES` (100, in `sessionContext.ts`) does not reset the in-memory lap/decimation state at the loop boundary — the next pass carries over stale state instead of starting fresh. Not expected to matter for any realistic recording (the published Interlagos example spans about 4,500 frames). +- The corner geometry thresholds (`src/analysis/trackGeometry.ts`: radius up to 100 m, at least 20° of turning) were validated only on Interlagos; on a track whose fast corners have a larger radius those corners may not appear. Revalidate them when a recording of another track exists. +- A corner's apex depends on the pair of laps compared: the geometric apex comes from the mean path of the two laps, so the same corner reads a few metres differently from pair to pair (in (8,6) and (8,7), the pairs the demo shows: 390/385, 1430/1440, 2130/2110, 2785/2780 and 3275/3290 m). Future work: a fixed corner map per track, together with revalidating the geometry thresholds on other tracks. +- A lap without motion samples gets no corners (`corners: []`); the total delta and the tyre windows are still computed. +- `corner.brakingStartDistance` and `corner.exitDistance` hold the start and end of the corner's stretch, not a braking point and a throttle pick-up. The names are kept for the frontend's contract; renaming them is debt for the frontend's spec. - Switching the Node major without running `npm ci` again makes `better-sqlite3` fail to load with `ERR_DLOPEN_FAILED` (its native binary is built for one major's ABI). - `sessionType` 18 is Time Trial in F1 24 (the numbering differs from F1 23). - `.env` is read only at startup; `ts-node-dev` does not restart on `.env` changes. - The first start of a version with a schema change migrates `data/telemetry.db` in place (this version adds `lap_analyses.prompt_version`); back it up first to a folder outside the repository, with the `VACUUM INTO` command in the README ("Upgrading an existing database"). -- Without `ANTHROPIC_API_KEY`, `POST /sessions/:id/analysis` returns 503; analyses cached with the current prompt version and model are still served, older ones answer 503. -- The coach's system prompt is cacheable for `claude-opus-4-8`: the documented minimum is 1,024 tokens and the current prompt measures 2,172 (an estimate from the free token-counting endpoint). `cache_control` works with this model: the previous prompt showed `cache_write=1380` and then `cache_read=1380` on 2026-10-23; the current prompt has not been sent yet. Each call logs its token `usage` (`[coachService] usage ...`). Re-measure if the prompt changes. +- Without `ANTHROPIC_API_KEY`, `POST /sessions/:id/analysis` returns 503; analyses cached with the current prompt version, model and insights are still served (stored insights are compared with freshly computed ones by `insightsMatch`), older ones answer 503, and a pair whose samples are gone is not served from the cache and answers 400 (no samples), with or without the key. +- The coach's system prompt is cacheable for `claude-opus-4-8`: the documented minimum is 1,024 tokens and the current prompt (`PROMPT_VERSION` `0cb0df9eaefa147e`) measured 2,600 tokens, above it (an estimate from the free token-counting endpoint: the prompt plus a one-character message, 2,607, minus the message alone, 7). `cache_control` works with this model: the prompt that existed before `PROMPT_VERSION` was introduced (spec 004; it has no version) showed `cache_write=1380` and then `cache_read=1380` on 2026-10-23; the current prompt (`0cb0df9eaefa147e`) has not been sent yet. Each call logs its token `usage` (`[coachService] usage ...`). Re-measure if the prompt changes. ## How to work in this repo diff --git a/README.md b/README.md index e78ec3f..fde7847 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ flowchart LR | `src/http/` | Express REST API | | `src/replay/` | Pure replay pacing (`computeDelayMs`) and the orchestration loop (`runReplay`): speed, loop, absolute-deadline scheduling so error does not accumulate over a long recording | | `src/tooling/` | Tests of the project's own tooling: the test of the empty-discovery guard | -| `tools/` | Recorder, an offline `analyze` CLI (insights, coach message, prompt version), a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | +| `tools/` | Recorder, an offline `analyze` CLI (insights, coach message, prompt version), `exportRecordingCut.ts` (regenerates `src/analysis/recordingCut.json` from the published recording), a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | ## Features @@ -58,7 +58,7 @@ flowchart LR | GET | `/sessions/:id/laps/:lapNumber/telemetry` | Decimated car telemetry and motion samples for a lap | | POST | `/sessions/:id/analysis` | Body `{ lapA, lapB }`; returns `{ analysis, insights, cached }` | -- **Lap analysis engine** (`npm run analyze`, also used by the coach): corner detection from the speed trace, braking point and trail braking, throttle pick-up, coasting and throttle oscillation, per-corner time loss, lateral line deviation between laps, and tyre surface temperature excursions. +- **Lap analysis engine** (`npm run analyze`, also used by the coach): corner detection from the geometry of the two laps' mean path (the same corners whichever lap is the primary, each with its own stretch of track), braking point and peak, trail braking, throttle pick-up, coasting and throttle oscillation (each measured per lap inside the corner's own stretch; a lap that did not brake, or did not reach full throttle before the next corner, is reported as such rather than as a difference), per-corner time loss, lateral line deviation between laps, and tyre surface temperature excursions. - **AI coach**: turns the insights of two laps into pit-engineer style advice in Portuguese (the code picks the opportunities), with results cached per lap pair. - **Recorder and replayer** to capture a real session once and work without the game afterwards, replaying at the original pace by default, with `1x`/`2x`/`max` speed options and an optional loop for a continuous demo. @@ -74,7 +74,7 @@ npm run dev In the game, enable UDP telemetry under *Settings → Telemetry*: IP `127.0.0.1`, port `20777`, UDP format `2024`. -Without `ANTHROPIC_API_KEY` everything works except new coach analyses, which return HTTP 503. Analyses cached with the current prompt version and model are still served; older ones answer 503. +Without `ANTHROPIC_API_KEY` everything works except new coach analyses, which return HTTP 503. Analyses cached with the current prompt version, model and insights are still served; older ones answer 503. A cached analysis counts as current only while its stored insights match the ones computed now from the pair's samples (same tolerance rule as the demo seed); a pair whose samples are gone is not served from the cache and answers 400 (no samples), with or without the key. **Upgrading an existing database.** The first start of this version adds the column `lap_analyses.prompt_version` to `data/telemetry.db` in place. Before it, with the API stopped, back the database up to a folder outside the repository (the command creates it) and keep the copy until the change is merged: @@ -84,7 +84,7 @@ node -e "require('fs').mkdirSync('C:/Telemetry/backups',{recursive:true}); const ### Running without the game -A sample recording is published as a release asset (not committed to git): [sample-recording-v1](https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/tag/sample-recording-v1). +A sample recording is published as a release asset (not committed to git): [sample-recording-v1](https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/tag/sample-recording-v1). The one exception is `src/analysis/recordingCut.json`, a cut of laps 6–8 of this recording with only the fields the analysis engine reads (under 660,000 bytes, checked by a test), versioned so the CI can test the engine on real laps. It holds none of the other packets and comes from data already public here; `tools/exportRecordingCut.ts` regenerates it. | | | |---|---| @@ -172,9 +172,9 @@ Last resort, if GitHub Actions itself is down: wait first. Only if it stays down The game's send rate sets the other limit. The recording used for validation was made at **20 Hz**. At 300 km/h the car covers about 4.2 m between packets, so at high speed the send rate, not the decimation, is the bottleneck; in slower sections decimation keeps one sample per 4 m. Either way the stored resolution is roughly one point every 4 m along the lap, and braking points, throttle pick-up and line deviation cannot be located more precisely than that. A 60 Hz recording would give about three times the raw density at the same speed, with decimation then capping it at 4 m. -**The coach receives insights, never raw samples.** The deterministic engine extracts the facts (where, how much, which input differed) and only that JSON goes to the model, together with the compared laps' number, official time and validity and the official difference between them. The model no longer prioritises: the code ranks and labels up to three opportunities with the frontend's rule (corners that lost time, largest first) and writes the direction of each difference in words, so the model only explains them. No benchmark (personal or theoretical best) is sent. Raw samples would cost far more tokens, invite numerical hallucination and make the analysis impossible to test. All the arithmetic lives in `src/analysis/`, covered by unit tests. +**The coach receives insights, never raw samples.** The deterministic engine extracts the facts (where, how much, which input differed) and only that JSON goes to the model, together with the compared laps' number, official time and validity and the official difference between them. The model no longer prioritises: the code ranks and labels up to three opportunities with the frontend's rule (corners that lost time, largest first) and writes the direction of each difference in words, so the model only explains them. It is also told when a lap did not brake in a corner (it only lifted) or did not reach full throttle before the next one, instead of being given a difference that does not exist. No benchmark (personal or theoretical best) is sent. Raw samples would cost far more tokens, invite numerical hallucination and make the analysis impossible to test. All the arithmetic lives in `src/analysis/`, covered by unit tests. -**Static system prompt.** The coach's system prompt is a constant, never interpolated, and all variable content goes in the user message. This is the precondition for Anthropic prompt caching, and the request marks the system block with `cache_control`. **What happens today:** Anthropic only caches a prefix above a model-specific minimum length ([prompt caching docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)); a shorter one is processed normally, with no cache entry and no error. For `claude-opus-4-8` the documented minimum is 1,024 tokens, and the current prompt measures 2,172 tokens: an estimate from Anthropic's token-counting endpoint (free; it does not use the cache logic), taken as the count of the prompt plus a one-character user message (2,179) minus the count of that message alone (7). It is above the minimum, so the system block is cacheable. Caching with this model was observed on the previous prompt: in the demo seed export of 2026-10-23 the usage log showed `cache_write=1380` on the first call and `cache_read=1380` on the next five. The current prompt has not been sent to the API yet, so its own cache behaviour is expected, not observed. Each call logs its token usage (`[coachService] usage ... cache_write=… cache_read=…`) to confirm this at runtime. What actually avoids repeated cost is the `lap_analyses` table: an analysed lap pair is sent again only when the prompt version or the model changes, and concurrent requests for the same pair share one call. (Before, two simultaneous requests for a new pair made two paid calls and the second ended in a 500 on `SQLITE_CONSTRAINT_UNIQUE`, discarding its paid text.) The coach's texts follow the prompt only as far as the model obeys it, which no test can check, so generated texts are read by a person before they are published. +**Static system prompt.** The coach's system prompt is a constant, never interpolated, and all variable content goes in the user message. This is the precondition for Anthropic prompt caching, and the request marks the system block with `cache_control`. **What happens today:** Anthropic only caches a prefix above a model-specific minimum length ([prompt caching docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)); a shorter one is processed normally, with no cache entry and no error. For `claude-opus-4-8` the documented minimum is 1,024 tokens, and the current prompt (`PROMPT_VERSION` `0cb0df9eaefa147e`) measured **2,600 tokens** for `claude-opus-4-8`, above the minimum. The figure is an estimate from Anthropic's token-counting endpoint (free, not using the cache logic), taken as the count of the prompt plus a one-character user message (2,607) minus the count of that message alone (7). Re-measure it if the prompt changes. The current prompt has not been sent to the API with caching yet, so its own cache behaviour is expected, not observed. Each call logs its token usage (`[coachService] usage ... cache_write=… cache_read=…`) to confirm this at runtime. What actually avoids repeated cost is the `lap_analyses` table: an analysed lap pair is sent again only when the prompt version, the model or its insights change, and concurrent requests for the same pair share one call. (Before, two simultaneous requests for a new pair made two paid calls and the second ended in a 500 on `SQLITE_CONSTRAINT_UNIQUE`, discarding its paid text.) The coach's texts follow the prompt only as far as the model obeys it, which no test can check, so generated texts are read by a person before they are published. **Partial session-packet parser.** Only `sessionType` and `trackId` are read. The full struct contains large variable-length arrays the project does not use yet, so this parser is the only one that does not assert the total byte count. @@ -186,6 +186,10 @@ The game's send rate sets the other limit. The recording used for validation was - **The `max` replay speed loses most samples by design** — it exists to reach the end of a long recording quickly when fidelity does not matter. `1x` and `2x` do not lose samples. - **`--speed=max` combined with `--loop` delivers practically no samples**: with no gap between an already-fast pass and the next, the flood is continuous rather than a one-off burst, and measured delivery dropped to effectively zero. For a continuous public demo, use `1x` or `2x`. - **A looped replay of a very short recording** does not reset its in-memory lap/decimation state at the loop boundary: if a recording's own `overallFrameIdentifier` span is 100 frames or less (roughly 5 seconds), the next pass carries over stale state instead of starting fresh. Recordings of a lap or more are unaffected — the published Interlagos recording spans about 4,500 frames. +- **The corner geometry thresholds were validated only on Interlagos.** A corner is a stretch of the two laps' mean path with a radius up to 100 m and at least 20° of turning; on a track whose fast corners have a larger radius those corners may not appear. They must be revalidated when a recording of another track exists. +- **A corner's apex depends on the pair of laps compared.** The geometric apex comes from the mean path of the two laps, so the same corner reads a few metres differently from pair to pair (in (8,6) and (8,7), the pairs the demo shows: 390/385, 1430/1440, 2130/2110, 2785/2780 and 3275/3290 m). Future work: a fixed corner map per track, together with revalidating the geometry thresholds on other tracks. +- **A lap without motion samples gets no corners** (`corners: []`); the total delta and the tyre windows are still computed. +- **`corner.brakingStartDistance` and `corner.exitDistance` are the start and end of the corner's stretch**, not a braking point and a throttle pick-up. The names are kept for the frontend's contract; renaming them is left to the frontend's spec. - **Error responses** are plain strings in mixed languages, and the frontend matches some of them literally. - **Switching the Node major without running `npm ci` again** makes `better-sqlite3` fail to load with `ERR_DLOPEN_FAILED`: its native binary is built for one major's ABI. - No build or lint scripts yet. diff --git a/specs/005-analysis-engine-artifacts/checklists/requirements.md b/specs/005-analysis-engine-artifacts/checklists/requirements.md new file mode 100644 index 0000000..004b5b1 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Fix the analysis engine's artifacts + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-10-24 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [ ] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The three [NEEDS CLARIFICATION] markers (FR-009, FR-012, FR-018) were resolved at the clarify session of 2026-10-24, together with four more questions (spec, Clarifications). +- As in specs 001–004, the Background section and some requirements name files and fields (`LapInsights`, `segmentDelta.ts`, `PROMPT_VERSION`): the feature is a correction of an existing engine and its contract, and the reader is the maintainer, so the "non-technical stakeholders" item is consciously not met. The requirements say what must hold, not how to implement it; the algorithms are left to the plan. +- Tolerances are deliberately not given as numbers: FR-014 requires them to be fixed in the plan from the data's resolution before any correction, and never widened afterwards. diff --git a/specs/005-analysis-engine-artifacts/contracts/coach-reading.md b/specs/005-analysis-engine-artifacts/contracts/coach-reading.md new file mode 100644 index 0000000..ea67f29 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/contracts/coach-reading.md @@ -0,0 +1,34 @@ +# Contract: the coach's prompt and reading (spec 004's code) + +What changes in `src/coaching/` because the insights change. The exact Portuguese wording is written in the implementation and read by the maintainer before any paid call (`npm run analyze` prints the message and the version). + +## System prompt (`coachPrompt.ts`), "Estrutura do JSON" + +| Line today | Change | +|---|---| +| `totalDeltaS`: "delta acumulado medido pelas amostras. Não é a diferença oficial de tempo de volta; não o cite." | It now agrees with the official difference to a few hundredths; the instruction stays to open with the official difference of the message and not to cite `totalDeltaS`, so the text says it is a sample-based measure close to the official difference, and still forbids citing it. | +| `corners`: "curvas detectadas na volta principal" | the track's corners (places), the same whichever lap is the primary | +| — | `corner.brakingStartDistance` / `exitDistance`: the corner's stretch, from the end of the previous straight to the end of the next one; not a braking point | +| `deltaLossS` | time lost over the corner's stretch, including the exit onto the next straight | +| `braking.brakingPointDiffM` | `null` when one of the laps did not brake in that corner | +| — | `braking.brakedPrimary` / `brakedComparison`: whether each lap braked in that corner; `false` means it only lifted or went flat | +| `throttle.throttleOnDiffM` | `null` when one of the laps did not reach full throttle before the next corner | +| — | `throttle.fullThrottlePrimary` / `fullThrottleComparison`: whether each lap reached full throttle inside the corner | +| coasting | from the lap's slowest point to its full-throttle pick-up (or the end of the corner) | + +Task rules 4 and 10 already forbid contradicting a field and citing field names; a new rule: never say a lap braked when its `braked*` is false. + +## Reading (`coachReading.ts`) + +New phrases in each opportunity, from the booleans: + +- one lap braked and the other did not: which lap only lifted (or went flat) and which braked; neither braked: both lifted or went flat. Placed first, at the top of the braking lines; +- one lap did not reach full throttle before the next corner: which one. Placed in the throttle slot, after the trail braking phrase and where the throttle pick-up phrase goes (the two never appear together: this phrase exists only when the pick-up difference is `null`). + +Each opportunity therefore reads in the order of the corner: braking (who braked, point, pressure, trail braking), then throttle (full throttle or pick-up), then coasting. Order set by the maintainer at M1 (2026-10-25). + +The braking-point and throttle phrases keep their rules and are omitted when the difference is `null`, as today. The opportunity rule and labels do not change. + +## `PROMPT_VERSION` (`promptVersion.ts`) + +Changes: the prompt text, the new phrase templates (and the test that every template appears in some reference message), and `REFERENCE_INPUTS` with the four new fields, including at least one `false` of each. No other cause; a test keeps checking that the same prompt, builder and reference inputs give the same version. diff --git a/specs/005-analysis-engine-artifacts/contracts/insights.md b/specs/005-analysis-engine-artifacts/contracts/insights.md new file mode 100644 index 0000000..1b67dee --- /dev/null +++ b/specs/005-analysis-engine-artifacts/contracts/insights.md @@ -0,0 +1,51 @@ +# Contract: `LapInsights` in `POST /sessions/:id/analysis` + +The response stays `{ analysis, insights, cached }` with the same status codes and error bodies. Only `insights` changes. The frontend mirrors it in `Telemetry-F1/src/types/rest.ts`; this file is the list for the frontend spec (constitution, principle VI). + +## Shape + +| Path | Before | After | +|---|---|---| +| `totalDeltaS` | `number` | `number` (same) | +| `corners[]` | `CornerInsight[]` | same | +| `corners[].corner.{index, apexDistance, apexSpeed, brakingStartDistance, exitDistance, severity}` | as today | same names and types | +| `corners[].deltaLossS` | `number` | same | +| `corners[].braking.{brakingPointDiffM, peakBrakeDiff}` | `number \| null` | same | +| `corners[].braking.{trailBrakingPrimary, trailBrakingComparison}` | `boolean` | same | +| `corners[].braking.brakedPrimary` | — | **added** `boolean \| null` | +| `corners[].braking.brakedComparison` | — | **added** `boolean \| null` | +| `corners[].throttle.throttleOnDiffM` | `number \| null` | same | +| `corners[].throttle.fullThrottlePrimary` | — | **added** `boolean \| null` | +| `corners[].throttle.fullThrottleComparison` | — | **added** `boolean \| null` | +| `corners[].throttle.{coasting*, throttleOscillation*}` | `number` | same | +| `corners[].lineDeviation` | `LineDeviationInsight \| null` | same | +| `tyreWindowExcursions` | as today | same | + +No field is removed or renamed (plan, O5, decided 2026-10-25). **Debt for the frontend spec:** `corner.brakingStartDistance` now means the start of the corner's stretch, not a braking point, and `corner.exitDistance` its end; renaming them is that spec's decision. + +## Meaning + +| Field | Before | After | +|---|---|---| +| `totalDeltaS` | last value of a series normalized by each lap's first sample, comparison clamped at its last sample; not symmetric, up to 0.13 s from the official difference | delta at the last distance both laps cover, starts extrapolated to 0 m; symmetric; within 0.021 s of the official difference on the recording (tolerance 0.045 s) | +| `corners` | speed minima of the **primary** lap; (A,B) and (B,A) may differ | places from the track's geometry; the same in both directions | +| `corner.apexDistance` | primary lap's slowest point (smoothed) | highest curvature of the two laps' mean path | +| `corner.apexSpeed` | primary lap's speed at its apex | primary lap's minimum speed in the stretch | +| `corner.brakingStartDistance` / `exitDistance` | primary lap's braking start / full-throttle pick-up (could equal the apex, could overlap the next corner) | start / end of the corner's stretch: contiguous, never overlapping | +| `corner.severity` | from the primary lap's apex speed | from the mean of both laps' minimum speeds | +| `deltaLossS` | over the primary lap's own braking start → pick-up | over the stretch; the stretches' losses add up to `totalDeltaS`; antisymmetric | +| `brakingPointDiffM` | could compare a braking point with an apex (100–190 m) | `null` unless both laps braked in the stretch | +| `peakBrakeDiff` | could be measured over a zero-length range | over each lap's own braking phase; 0 for a lap that did not brake | +| `throttleOnDiffM` | could reuse the next corner's pick-up | `null` unless both laps reached full throttle in the stretch | +| `coasting*`, `throttleOscillation*` | from the "apex" found within ±80 m | from the lap's slowest point in the stretch | + +## HTTP status change: a pair without samples + +`POST /sessions/:id/analysis` checks the pair's samples first (FR-018: the stored insights cannot be compared without them). A pair without samples answers **400** (no samples), whatever the cache holds and whatever the key configuration. Before this feature an uncached pair without samples and without the key answered **503**. The message already exists and the frontend already handles it; the response shape and the error strings do not change. + +## What the visitor sees change + +- Coach chips `. m · +s`: other distances and losses (same rule). +- Corner markers on the charts (`apexDistance`) and the highlighted corner (`brakingStartDistance`–`exitDistance`, now the whole stretch). +- The coach texts (regenerated). +- The "Delta acumulado" chart only after the frontend spec ports the series. diff --git a/specs/005-analysis-engine-artifacts/data-model.md b/specs/005-analysis-engine-artifacts/data-model.md new file mode 100644 index 0000000..1d8a2e0 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/data-model.md @@ -0,0 +1,83 @@ +# Data Model: Fix the analysis engine's artifacts + +Pure values computed by `src/analysis/`; nothing is persisted except the insights JSON already stored in `lap_analyses.insights_json` (no schema change). Decisions referenced as D1–D9 are in [plan.md](plan.md). + +## Common grid + +The distances both laps cover, `[max(firstA, firstB), min(lastA, lastB)]`, at 5 m. Every quantity computed from both laps (mean path, mean speed) is computed on it by linear interpolation of each lap's stored samples; the same inputs in either order give the same grid and the same values, so the result is symmetric by construction. + +## Corner (a place) + +| Field | Meaning | Source | +|---|---|---| +| `index` | position in the lap, 0-based; internal, never shown | order of the geometric apexes | +| `apexDistance` | distance of the highest curvature of the two laps' mean path | D1 | +| `apexSpeed` | the **primary** lap's minimum speed inside the stretch (km/h) | D3, D9 | +| `brakingStartDistance` | the stretch's start (name kept, O5) | D2 | +| `exitDistance` | the stretch's end (name kept, O5) | D2 | +| `severity` | `slow` ≤ 110 < `medium` ≤ 180 < `fast`, on the mean of both laps' minimum speeds in the stretch | D9 | + +Rules: + +- A corner is a run of grid points with curvature of the same sign and radius ≤ 100 m whose turning angle is ≥ 20° (curvature smoothed over 7 points = 35 m). +- Stretches are contiguous and do not overlap: the boundary between corners *i* and *i*+1 is the grid point of highest mean speed strictly between their apexes; the first stretch starts at the grid's start, the last ends at its end. +- (A,B) and (B,A) have the same corners, apex for apex, and the same stretches. +- Without motion samples for either lap: no corners (O4). + +## Behaviour of a lap in a corner + +Measured on the lap's own stored samples with `lapDistance` inside the stretch. + +| Value | Rule | When it does not exist | +|---|---|---| +| slowest point | the sample with the lowest speed (first one on ties) | the lap has no sample in the stretch → everything below is unknown (`null`) | +| braking start | walking back from the slowest point, the last braking phase (consecutive samples with brake > 0.1) that ends at or before it; its first sample | no such phase in the stretch → **did not brake** (`braked = false`) | +| braking peak | the highest brake over that phase | 0 when the lap did not brake | +| trail braking | brake > 0.1 with \|steer\| > 0.15 between the braking start and the slowest point | `false` when it did not brake | +| full-throttle pick-up | the first sample at throttle ≥ 0.9 from the slowest point on (the slowest sample itself included), inside the stretch | none before the stretch ends → **no full throttle** (`fullThrottle = false`) | +| coasting time | time with brake ≤ 0.05 and throttle ≤ 0.05 from the slowest point to the pick-up, or to the stretch's end | — | +| throttle oscillation | direction changes of the throttle (steps ≥ 0.02) over the same range | — | + +## CornerInsight (per corner, primary − comparison) + +| Field | Rule | +|---|---| +| `deltaLossS` | `delta(exitDistance) − delta(brakingStartDistance)` on the series below, both laps interpolated at the bounds; the stretches' losses add up to `totalDeltaS` | +| `braking.brakingPointDiffM` | difference of the braking starts; `null` unless both laps braked | +| `braking.peakBrakeDiff` | difference of the peaks (a lap that did not brake has peak 0); `null` only when a lap has no samples | +| `braking.trailBrakingPrimary` / `Comparison` | as above | +| `braking.brakedPrimary` / `brakedComparison` | **new**, `boolean \| null` | +| `throttle.throttleOnDiffM` | difference of the pick-ups; `null` unless both laps reached full throttle | +| `throttle.fullThrottlePrimary` / `fullThrottleComparison` | **new**, `boolean \| null` | +| `throttle.coastingTime*S`, `throttleOscillation*` | per lap, as above | +| `lineDeviation` | `summarizeLineDeviation` over the stretch, unchanged otherwise | + +## Delta series and `totalDeltaS` + +- Each lap's start time: its first sample's `sessionTime` minus the time to cover its first sample's `lapDistance` at the speed implied by its first two samples (start extrapolated to 0 m, D5). +- `delta(d) = elapsedPrimary(d) − elapsedComparison(d)`, both interpolated at `d`, for `d` in the common range. +- The series returned (for the frontend's chart, if it adopts it) has a point at each of the primary lap's sample distances inside the common range. +- `totalDeltaS = delta(end of the common range)`, rounded to 0.01. + +## Tolerances (D6) + +| Property | Tolerance | +|---|---| +| corners of (A,B) and (B,A) | identical | +| each difference and each `deltaLossS`, (A,B) + (B,A) | ≤ 0.01 + 1e-9 | +| `totalDeltaS` − official difference | ≤ 0.045 s | +| `totalDeltaS`, (A,B) + (B,A) | ≤ 0.01 + 1e-9 | + +## Cached analysis (local cache and demo seed) + +Current when prompt version = current, model = current, and `insightsMatch(storedInsights, computedInsights)` (structure equal; numbers within 0.01 + 1e-9). Otherwise stale: regenerated with the key, 503 without; if the pair's samples are gone, the request answers as today for a pair without samples. + +## Recording cut (`src/analysis/recordingCut.json`) + +```text +{ sourceSha256, laps: [ { lapNumber, lapTimeMs, isValid, + telemetry: [ { lapDistance, sessionTime, speed, throttle, brake, steer, tyreSurfaceTemp: {4 wheels} } ], + motion: [ { lapDistance, worldPosition: { x, z } } ] } ] } +``` + +Numbers with 9 significant digits (lossless for float32). Laps 6, 7, 8 whole. Ceiling 660,000 bytes. The field list is re-checked against the final engine when the tool is written (only what `src/analysis/` reads). diff --git a/specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts b/specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts new file mode 100644 index 0000000..16cf2d7 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts @@ -0,0 +1,89 @@ +// Uso: DB_PATH= node -r ts-node/register buildResearchDb.ts +// Monta a sessão da gravação pela ingestão da main (dispatchPacket + persistTelemetry, como src/server.ts), +// sem socket UDP, e grava a série crua do carro do jogador (todos os pacotes 0, 2 e 6) para a investigação. +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; +import { createHash } from "node:crypto"; + +async function main(): Promise { + const [recordingPath, rawOutPath] = process.argv.slice(2); + const dbPathEnv = process.env.DB_PATH; + if (!recordingPath || !rawOutPath || !dbPathEnv) throw new Error("uso: DB_PATH=... buildResearchDb.ts "); + if (path.resolve(dbPathEnv).toLowerCase().includes(path.join("data", "telemetry.db").toLowerCase())) { + throw new Error("DB_PATH aponta para o banco real"); + } + + const sha = createHash("sha256").update(fs.readFileSync(recordingPath)).digest("hex"); + console.log(`sha256 da gravação: ${sha}`); + + const repo = path.resolve(__dirname, "..", "..", ".."); + const { db } = require(path.join(repo, "src/persistence/db")); + const { dbPath } = require(path.join(repo, "src/config/dbPath")); + const { dispatchPacket } = require(path.join(repo, "src/telemetry/dispatcher")); + const { persistTelemetry } = require(path.join(repo, "src/persistence/persistTelemetry")); + if (path.resolve(dbPath) !== path.resolve(dbPathEnv)) throw new Error(`banco aberto inesperado: ${dbPath}`); + + const raw: Record[] = []; + let lastLap: { lapNum: number; lapDistance: number; driverStatus: number; pitStatus: number; invalid: number } | null = null; + let packets = 0; + let dropped = 0; + + const rl = readline.createInterface({ input: fs.createReadStream(recordingPath), crlfDelay: Infinity }); + for await (const line of rl) { + if (!line.trim()) continue; + packets++; + const rec = JSON.parse(line) as { data: string }; + const buffer = Buffer.from(rec.data, "base64"); + let result; + try { + result = dispatchPacket(buffer); + } catch { + dropped++; + continue; + } + if (!result) continue; + const { header, packetId, data } = result as any; + if (![0, 1, 2, 6].includes(packetId)) continue; + persistTelemetry(header, packetId, data); + + const i = header.playerCarIndex; + const base = { + packetId, + sessionTime: header.sessionTime, + frame: header.frameIdentifier, + overallFrame: header.overallFrameIdentifier, + }; + if (packetId === 2) { + const p = data.lapData[i]; + lastLap = { + lapNum: p.currentLapNum, + lapDistance: p.lapDistance, + driverStatus: p.driverStatus, + pitStatus: p.pitStatus, + invalid: p.currentLapInvalid, + }; + raw.push({ ...base, ...lastLap, lastLapTimeInMS: p.lastLapTimeInMS, currentLapTimeInMS: p.currentLapTimeInMS }); + } else if (packetId === 6) { + const c = data.carTelemetryData[i]; + raw.push({ ...base, lapNum: lastLap?.lapNum ?? null, lapDistance: lastLap?.lapDistance ?? null, speed: c.speed, throttle: c.throttle, brake: c.brake, steer: c.steer, gear: c.gear }); + } + } + + fs.writeFileSync(rawOutPath, JSON.stringify(raw)); + const count = (table: string) => + db.prepare(`SELECT lap_number AS lap, COUNT(*) AS n FROM ${table} GROUP BY lap_number ORDER BY lap_number`).all(); + console.log(`banco: ${dbPath}`); + console.log(`pacotes lidos: ${packets}; descartados: ${dropped}`); + console.log(`sessões: ${JSON.stringify(db.prepare("SELECT id, session_uid, track_id FROM sessions").all())}`); + console.log(`voltas: ${JSON.stringify(db.prepare("SELECT lap_number, lap_time_ms, is_valid FROM laps ORDER BY lap_number").all())}`); + console.log(`car_telemetry_samples: ${JSON.stringify(count("car_telemetry_samples"))}`); + console.log(`motion_samples: ${JSON.stringify(count("motion_samples"))}`); + console.log(`série crua: ${raw.length} linhas em ${rawOutPath}`); + db.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/specs/005-analysis-engine-artifacts/evidence/cornerOptions.ts b/specs/005-analysis-engine-artifacts/evidence/cornerOptions.ts new file mode 100644 index 0000000..78c0527 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/evidence/cornerOptions.ts @@ -0,0 +1,261 @@ +// Uso: DB_PATH= node -r ts-node/register cornerOptions.ts +// Protótipos (só investigação, nada em src/) das opções para um mesmo conjunto de curvas em (A,B) e (B,A): +// P hoje, com a proeminência corrigida, na volta principal só (referência) +// A detectar nas duas voltas (proeminência corrigida) e casar os ápices +// B geometria da pista: curvatura do traçado (worldPosition x,z) média das duas voltas +// C velocidade média das duas voltas na mesma grade de distância, mínimos com proeminência corrigida +import path from "node:path"; + +const repo = path.resolve(__dirname, "..", "..", ".."); +if (!process.env.DB_PATH || /data[\\/]telemetry\.db$/i.test(process.env.DB_PATH)) throw new Error("DB_PATH ausente ou real"); +require(path.join(repo, "src/persistence/db")); +const { listCarTelemetrySamplesForLap } = require(path.join(repo, "src/persistence/carTelemetrySampleRepository")); +const { listMotionSamplesForLap } = require(path.join(repo, "src/persistence/motionSampleRepository")); +const { movingAverage } = require(path.join(repo, "src/analysis/smoothing")); + +const tel = (lap: number) => listCarTelemetrySamplesForLap(1, lap); +const mot = (lap: number) => listMotionSamplesForLap(1, lap); +const PROMINENCE = 15; +const MERGE_M = 50; +const GRID_M = 5; + +type Series = [number, number][]; + +// Proeminência topográfica: a subida de cada lado conta só até um valor mais baixo que o mínimo. +function minimaTopo(series: Series, minProminence: number): number[] { + const out: number[] = []; + for (let i = 1; i < series.length - 1; i++) { + const v = series[i][1]; + if (!(v < series[i - 1][1] && v <= series[i + 1][1])) continue; + const rise = (dir: number) => { + let r = 0; + for (let j = i + dir; j >= 0 && j < series.length; j += dir) { + if (series[j][1] < v) break; + r = Math.max(r, series[j][1] - v); + } + return r; + }; + if (rise(-1) >= minProminence && rise(1) >= minProminence) out.push(i); + } + const merged: number[] = []; + for (const i of out) { + const last = merged[merged.length - 1]; + if (last !== undefined && series[i][0] - series[last][0] < MERGE_M) { + if (series[i][1] < series[last][1]) merged[merged.length - 1] = i; + } else merged.push(i); + } + return merged; +} + +function interp(xs: number[], ys: number[], x: number): number | null { + if (x < xs[0] || x > xs[xs.length - 1]) return null; + for (let i = 0; i < xs.length - 1; i++) { + if (x >= xs[i] && x <= xs[i + 1]) { + const span = xs[i + 1] - xs[i]; + return span === 0 ? ys[i] : ys[i] + ((ys[i + 1] - ys[i]) * (x - xs[i])) / span; + } + } + return null; +} + +function grid(a: number[], b: number[]): number[] { + const from = Math.ceil(Math.max(a[0], b[0]) / GRID_M) * GRID_M; + const to = Math.min(a[a.length - 1], b[b.length - 1]); + const g: number[] = []; + for (let d = from; d <= to; d += GRID_M) g.push(d); + return g; +} + +function speedSeries(lap: number): Series { + return movingAverage(tel(lap).map((s: any) => [s.lapDistance, s.speed]), 5); +} + +function optionP(lap: number): number[] { + const s = speedSeries(lap); + return minimaTopo(s, PROMINENCE).map((i) => s[i][0]); +} + +function optionA(a: number, b: number, matchM: number): number[] { + const all = [...optionP(a).map((d) => ({ d, lap: a })), ...optionP(b).map((d) => ({ d, lap: b }))].sort((x, y) => x.d - y.d); + const clusters: { d: number; lap: number }[][] = []; + for (const p of all) { + const c = clusters[clusters.length - 1]; + if (c && p.d - c[c.length - 1].d <= matchM) c.push(p); + else clusters.push([p]); + } + return clusters.map((c) => c.reduce((s, p) => s + p.d, 0) / c.length); +} + +function optionC(a: number, b: number): number[] { + const sa = tel(a); + const sb = tel(b); + const xa = sa.map((s: any) => s.lapDistance); + const xb = sb.map((s: any) => s.lapDistance); + const g = grid(xa, xb); + const mean: Series = g.map((d) => [d, ((interp(xa, sa.map((s: any) => s.speed), d) ?? NaN) + (interp(xb, sb.map((s: any) => s.speed), d) ?? NaN)) / 2]); + const sm: Series = movingAverage(mean, 5); + return minimaTopo(sm, PROMINENCE).map((i) => sm[i][0]); +} + +// Curvatura (1/m, sinal = lado) do traçado médio das duas voltas numa grade de 5 m, suavizada em ~35 m. +function curvature(a: number, b: number): Series { + const ma = mot(a); + const mb = mot(b); + const xa = ma.map((s: any) => s.lapDistance); + const xb = mb.map((s: any) => s.lapDistance); + const g = grid(xa, xb); + const pos = g.map((d) => ({ + x: ((interp(xa, ma.map((s: any) => s.worldPosition.x), d) ?? NaN) + (interp(xb, mb.map((s: any) => s.worldPosition.x), d) ?? NaN)) / 2, + z: ((interp(xa, ma.map((s: any) => s.worldPosition.z), d) ?? NaN) + (interp(xb, mb.map((s: any) => s.worldPosition.z), d) ?? NaN)) / 2, + })); + const heading: number[] = []; + for (let i = 1; i < pos.length; i++) heading.push(Math.atan2(pos[i].z - pos[i - 1].z, pos[i].x - pos[i - 1].x)); + for (let i = 1; i < heading.length; i++) { + while (heading[i] - heading[i - 1] > Math.PI) heading[i] -= 2 * Math.PI; + while (heading[i] - heading[i - 1] < -Math.PI) heading[i] += 2 * Math.PI; + } + const k: Series = []; + for (let i = 1; i < heading.length; i++) k.push([g[i], (heading[i] - heading[i - 1]) / GRID_M]); + return movingAverage(k, 7); +} + +function optionB(a: number, b: number, maxRadiusM: number): { d: number; radius: number; side: string; angleDeg: number }[] { + const k = curvature(a, b); + const out: { d: number; radius: number; side: string; angleDeg: number }[] = []; + let i = 0; + while (i < k.length) { + if (Math.abs(k[i][1]) < 1 / maxRadiusM) { + i++; + continue; + } + const sign = Math.sign(k[i][1]); + let j = i; + let peak = i; + let angle = 0; + while (j < k.length && Math.sign(k[j][1]) === sign && Math.abs(k[j][1]) >= 1 / maxRadiusM) { + if (Math.abs(k[j][1]) > Math.abs(k[peak][1])) peak = j; + angle += k[j][1] * GRID_M; + j++; + } + out.push({ d: k[peak][0], radius: 1 / Math.abs(k[peak][1]), side: sign > 0 ? "+" : "-", angleDeg: (Math.abs(angle) * 180) / Math.PI }); + i = j; + } + return out; +} + +// Protótipo do desenho recomendado: curvas pela geometria (raio ≤ 100 m, ângulo ≥ 20°), trecho de cada curva entre +// os máximos da velocidade média das duas voltas, e o comportamento de cada volta medido dentro do trecho. +function design(a: number, b: number): void { + const corners = optionB(a, b, 100).filter((c) => c.angleDeg >= 20); + const sa = tel(a); + const sb = tel(b); + const xa = sa.map((s: any) => s.lapDistance); + const xb = sb.map((s: any) => s.lapDistance); + const g = grid(xa, xb); + const meanSpeed = g.map((d) => ((interp(xa, sa.map((s: any) => s.speed), d) ?? NaN) + (interp(xb, sb.map((s: any) => s.speed), d) ?? NaN)) / 2); + const bounds: number[] = [g[0]]; + for (let i = 0; i < corners.length - 1; i++) { + let best = -1; + for (let j = 0; j < g.length; j++) { + if (g[j] <= corners[i].d || g[j] >= corners[i + 1].d) continue; + if (best < 0 || meanSpeed[j] > meanSpeed[best]) best = j; + } + bounds.push(g[best]); + } + bounds.push(g[g.length - 1]); + + // Variante "aberto": dentro de cada partição, o trecho vai da última amostra em que as duas voltas estavam em pé + // embaixo (acelerador ≥ 0.9, freio ≤ 0.1) antes do ápice geométrico até a primeira em que as duas voltam a estar. + const open = process.env.STRETCH === "open"; + const flat = (s: any[], xs: number[], d: number) => { + let i = xs.findIndex((x) => x >= d); + if (i < 0) i = xs.length - 1; + return s[i].throttle >= 0.9 && s[i].brake <= 0.1; + }; + const both = (d: number) => flat(sa, xa, d) && flat(sb, xb, d); + const stretches = corners.map((c, i) => { + if (!open) return [bounds[i], bounds[i + 1]]; + let from = c.d; + while (from - GRID_M >= bounds[i] && !both(from)) from -= GRID_M; + let to = c.d; + while (to + GRID_M <= bounds[i + 1] && !both(to)) to += GRID_M; + // As duas voltas em pé embaixo no próprio ápice (curva feita sem tirar o pé): sobra só a vizinhança do ápice. + if (to - from < 2 * GRID_M) return [c.d - 2 * GRID_M, c.d + 2 * GRID_M]; + return [from, to]; + }); + + // Início de volta extrapolado a 0 m (alternativa 2) e delta nos dois sentidos na mesma grade. + const t0 = (s: any[]) => s[0].sessionTime - s[0].lapDistance / ((s[1].lapDistance - s[0].lapDistance) / (s[1].sessionTime - s[0].sessionTime)); + const ta = sa.map((s: any) => s.sessionTime - t0(sa)); + const tb = sb.map((s: any) => s.sessionTime - t0(sb)); + const deltaAt = (d: number) => (interp(xa, ta, d) ?? NaN) - (interp(xb, tb, d) ?? NaN); + + const behaviour = (s: any[], from: number, to: number) => { + const inS = s.filter((x) => x.lapDistance >= from && x.lapDistance < to); + let minI = 0; + inS.forEach((x, i) => (x.speed < inS[minI].speed ? (minI = i) : null)); + let k = minI; + while (k >= 0 && inS[k].brake <= 0.1) k--; + let brakeStart: number | null = null; + let peak = 0; + if (k >= 0) { + let m = k; + while (m > 0 && inS[m - 1].brake > 0.1) m--; + brakeStart = inS[m].lapDistance; + for (let q = m; q <= minI; q++) peak = Math.max(peak, inS[q].brake); + } + let t = minI; + while (t < inS.length && inS[t].throttle < 0.9) t++; + return { minAt: inS[minI].lapDistance, minSpeed: inS[minI].speed, brakeStart, peak, throttleOn: t < inS.length ? inS[t].lapDistance : null }; + }; + const fmt = (n: number | null) => (n === null ? "não" : n.toFixed(1)); + const diff = (p: number | null, q: number | null) => (p === null || q === null ? "null" : (p - q).toFixed(1)); + console.log(`\n(${a},${b}) — ${corners.length} curvas; total alt2 na última distância comum: ${deltaAt(g[g.length - 1]).toFixed(3)}`); + console.log("ápice geom. trecho | A: vel.mín@dist freada retomada | B: vel.mín@dist freada retomada | bpDiff thrDiff deltaLoss"); + let lossSum = 0; + corners.forEach((c, i) => { + const [from, to] = stretches[i]; + const pa = behaviour(sa, from, to); + const pb = behaviour(sb, from, to); + const loss = deltaAt(to) - deltaAt(from); + lossSum += loss; + console.log( + `${String(Math.round(c.d)).padStart(6)}${c.side} ${String(Math.round(from)).padStart(5)}–${String(Math.round(to)).padEnd(5)} | ${String(pa.minSpeed).padStart(4)}@${pa.minAt.toFixed(0).padEnd(5)} ${fmt(pa.brakeStart).padStart(7)} ${fmt(pa.throttleOn).padStart(8)} | ${String(pb.minSpeed).padStart(4)}@${pb.minAt.toFixed(0).padEnd(5)} ${fmt(pb.brakeStart).padStart(7)} ${fmt(pb.throttleOn).padStart(8)} | ${diff(pa.brakeStart, pb.brakeStart).padStart(6)} ${diff(pa.throttleOn, pb.throttleOn).padStart(8)} ${loss.toFixed(3).padStart(9)}` + ); + }); + console.log(`soma dos deltaLoss: ${lossSum.toFixed(3)}`); +} + +// Estabilidade dos lugares (opção B) em outros pares de voltas da mesma sessão: cornerOptions.ts places ... +if (process.argv[2] === "places") { + for (const pair of process.argv.slice(3)) { + const [a, b] = pair.split(",").map(Number); + const cs = optionB(a, b, 100).filter((c) => c.angleDeg >= 20); + console.log(`(${a},${b}): ${cs.length} curvas: ${cs.map((c) => `${Math.round(c.d)}${c.side}`).join(" ")}`); + } + process.exit(0); +} + +if (process.argv[2] === "design") { + for (const [a, b] of [[8, 6], [6, 8], [8, 7], [7, 8], [6, 7], [7, 6]]) design(a, b); + process.exit(0); +} + +const r0 = (xs: number[]) => xs.map((d) => Math.round(d)).join(", "); +console.log("P (só a volta principal, proeminência corrigida)"); +for (const lap of [6, 7, 8]) console.log(` volta ${lap}: ${optionP(lap).length} curvas: ${r0(optionP(lap))}`); +for (const [a, b] of [[6, 7], [6, 8], [7, 8]]) { + console.log(`\npar ${a}–${b}`); + for (const m of [40, 60, 80]) { + const x = optionA(a, b, m); + const y = optionA(b, a, m); + console.log(` A (casar ≤ ${m} m): ${x.length} curvas: ${r0(x)} | simétrico: ${r0(x) === r0(y)}`); + } + const c1 = optionC(a, b); + const c2 = optionC(b, a); + console.log(` C (velocidade média): ${c1.length} curvas: ${r0(c1)} | simétrico: ${r0(c1) === r0(c2)}`); + for (const rad of [100, 150, 250]) { + const g = optionB(a, b, rad); + console.log(` B (raio ≤ ${rad} m): ${g.length} trechos: ${g.map((c) => `${Math.round(c.d)}${c.side}(R${Math.round(c.radius)},${Math.round(c.angleDeg)}°)`).join(" ")}`); + } +} diff --git a/specs/005-analysis-engine-artifacts/evidence/inspect.ts b/specs/005-analysis-engine-artifacts/evidence/inspect.ts new file mode 100644 index 0000000..610195f --- /dev/null +++ b/specs/005-analysis-engine-artifacts/evidence/inspect.ts @@ -0,0 +1,361 @@ +// Uso: DB_PATH= node -r ts-node/register inspect.ts [args] +// corners curvas de cada par com os pontos que o motor usou em cada volta +// window amostras gravadas da volta entre duas distâncias +// delta totalDeltaS de hoje e definições alternativas, contra a diferença oficial +// losses deltaLossS curva a curva nos dois sentidos de cada par +// resolution espaçamento entre amostras gravadas (m e s) +// cut tamanho de um recorte das três voltas com só os campos que o motor usa +// Só lê o banco; nenhuma escrita. +import path from "node:path"; +import zlib from "node:zlib"; + +const repo = path.resolve(__dirname, "..", "..", ".."); +if (!process.env.DB_PATH || /data[\\/]telemetry\.db$/i.test(process.env.DB_PATH)) throw new Error("DB_PATH ausente ou real"); +const { db } = require(path.join(repo, "src/persistence/db")); +const { listCarTelemetrySamplesForLap } = require(path.join(repo, "src/persistence/carTelemetrySampleRepository")); +const { listMotionSamplesForLap } = require(path.join(repo, "src/persistence/motionSampleRepository")); +const { buildInsights } = require(path.join(repo, "src/analysis/buildInsights")); +const { detectCorners } = require(path.join(repo, "src/analysis/cornerDetection")); +const { computeDelta } = require(path.join(repo, "src/analysis/segmentDelta")); +const { findApexIndexNear, findBrakingStartIndex, findThrottleOnIndex } = require(path.join(repo, "src/analysis/sampleSearch")); + +const SESSION = 1; +const LAPS = [6, 7, 8]; +const PAIRS: [number, number][] = [[6, 7], [6, 8], [7, 6], [7, 8], [8, 6], [8, 7]]; +const tel = (lap: number) => listCarTelemetrySamplesForLap(SESSION, lap); +const mot = (lap: number) => listMotionSamplesForLap(SESSION, lap); +const lapTimeS = (lap: number) => + (db.prepare("SELECT lap_time_ms FROM laps WHERE session_id = ? AND lap_number = ?").get(SESSION, lap).lap_time_ms as number) / 1000; +const f = (n: number | null | undefined, d = 2) => (n === null || n === undefined ? "null" : n.toFixed(d)); +const pad = (s: string, n: number) => s.padStart(n); + +function insights(a: number, b: number) { + return buildInsights({ primaryTelemetry: tel(a), comparisonTelemetry: tel(b), primaryMotion: mot(a), comparisonMotion: mot(b) }); +} + +function corners(): void { + for (const lap of LAPS) { + const cs = detectCorners(tel(lap)); + console.log(`volta ${lap} como principal: ${cs.length} curvas, ápices ${cs.map((c: any) => Math.round(c.apexDistance)).join(", ")}`); + } + for (const [a, b] of PAIRS) { + const pa = tel(a); + const pb = tel(b); + const ins = insights(a, b); + console.log(`\npar (${a},${b})`); + console.log("idx ápice | P:ápice P:freada P:retomada | C:ápice C:freada C:retomada | bpDiff peakDiff thrDiff deltaLoss"); + for (const ci of ins.corners) { + const c = ci.corner; + const ia = findApexIndexNear(pa, c.apexDistance, 80); + const ib = findApexIndexNear(pb, c.apexDistance, 80); + const pt = (s: any[], i: number | null) => { + if (i === null) return ["-", "-", "-"]; + return [s[i].lapDistance, s[findBrakingStartIndex(s, i, 0.1)].lapDistance, s[findThrottleOnIndex(s, i, 0.9)].lapDistance].map((x: number) => x.toFixed(1)); + }; + const [aA, aB, aT] = pt(pa, ia); + const [bA, bB, bT] = pt(pb, ib); + console.log( + `${pad(String(c.index), 3)} ${pad(c.apexDistance.toFixed(0), 6)} | ${pad(aA, 7)} ${pad(aB, 9)} ${pad(aT, 10)} | ${pad(bA, 7)} ${pad(bB, 9)} ${pad(bT, 10)} | ` + + `${pad(f(ci.braking.brakingPointDiffM), 7)} ${pad(f(ci.braking.peakBrakeDiff), 8)} ${pad(f(ci.throttle.throttleOnDiffM), 8)} ${pad(f(ci.deltaLossS), 9)}` + ); + } + } +} + +function windowCmd(lap: number, from: number, to: number): void { + const rows = db + .prepare( + `SELECT lap_distance, speed, brake, throttle, steer, session_time, overall_frame_identifier FROM car_telemetry_samples + WHERE session_id = ? AND lap_number = ? AND lap_distance BETWEEN ? AND ? ORDER BY overall_frame_identifier` + ) + .all(SESSION, lap, from, to); + console.log(`volta ${lap}, ${from}–${to} m (${rows.length} amostras)`); + console.log(" dist(m) vel freio acel volante sessionTime overallFrame"); + for (const r of rows) { + console.log( + `${pad(r.lap_distance.toFixed(1), 9)} ${pad(String(r.speed), 4)} ${pad(r.brake.toFixed(2), 6)} ${pad(r.throttle.toFixed(2), 5)} ${pad(r.steer.toFixed(2), 8)} ${pad(r.session_time.toFixed(3), 12)} ${pad(String(r.overall_frame_identifier), 13)}` + ); + } +} + +// Tempo decorrido na distância d, interpolado; null fora do intervalo coberto pelas amostras. +function elapsedAt(s: { lapDistance: number; sessionTime: number }[], t0: number, d: number): number | null { + for (let i = 0; i < s.length - 1; i++) { + if (d >= s[i].lapDistance && d <= s[i + 1].lapDistance) { + const span = s[i + 1].lapDistance - s[i].lapDistance; + const r = span === 0 ? 0 : (d - s[i].lapDistance) / span; + return s[i].sessionTime + (s[i + 1].sessionTime - s[i].sessionTime) * r - t0; + } + } + return null; +} + +function delta(): void { + for (const lap of LAPS) { + const s = tel(lap); + const first = s[0]; + const last = s[s.length - 1]; + console.log( + `volta ${lap}: ${s.length} amostras, primeira ${first.lapDistance.toFixed(1)} m em t=${first.sessionTime.toFixed(3)}, ` + + `última ${last.lapDistance.toFixed(1)} m em t=${last.sessionTime.toFixed(3)}, ` + + `duração amostrada ${(last.sessionTime - first.sessionTime).toFixed(3)} s, oficial ${lapTimeS(lap).toFixed(3)} s` + ); + } + console.log("\npar oficial hoje(total) alt1(fim-fim) alt2(t0 extrapolado)"); + for (const [a, b] of PAIRS) { + const official = lapTimeS(a) - lapTimeS(b); + const series = computeDelta(tel(a), tel(b)); + const today = series[series.length - 1][1]; + const sa = tel(a); + const sb = tel(b); + // alt1: delta na última distância coberta pelas duas voltas, mais a diferença oficial que sobra fora dela: não usado; só o comum + const dEnd = Math.min(sa[sa.length - 1].lapDistance, sb[sb.length - 1].lapDistance); + const alt1 = (elapsedAt(sa, sa[0].sessionTime, dEnd) ?? NaN) - (elapsedAt(sb, sb[0].sessionTime, dEnd) ?? NaN); + // alt2: t0 de cada volta extrapolado para a distância 0 a partir das duas primeiras amostras + const t0 = (s: any[]) => { + const [p, q] = [s[0], s[1]]; + const v = (q.lapDistance - p.lapDistance) / (q.sessionTime - p.sessionTime); + return p.sessionTime - p.lapDistance / v; + }; + const alt2 = (elapsedAt(sa, t0(sa), dEnd) ?? NaN) - (elapsedAt(sb, t0(sb), dEnd) ?? NaN); + console.log(`(${a},${b}) ${pad(official.toFixed(3), 7)} ${pad(today.toFixed(3), 11)} ${pad(alt1.toFixed(3), 13)} ${pad(alt2.toFixed(3), 20)}`); + } +} + +function losses(): void { + for (const [a, b] of [[6, 7], [6, 8], [7, 8]]) { + const ab = insights(a, b); + const ba = insights(b, a); + console.log(`\n(${a},${b}) ${ab.corners.length} curvas / (${b},${a}) ${ba.corners.length} curvas`); + console.log("ápice(A,B) deltaLoss(A,B) | ápice(B,A) deltaLoss(B,A) | soma"); + for (const c of ab.corners) { + const m = ba.corners.find((x: any) => Math.abs(x.corner.apexDistance - c.corner.apexDistance) <= 40); + console.log( + `${pad(c.corner.apexDistance.toFixed(0), 10)} ${pad(f(c.deltaLossS), 14)} | ${pad(m ? m.corner.apexDistance.toFixed(0) : "-", 10)} ${pad(m ? f(m.deltaLossS) : "-", 14)} | ${m ? f(c.deltaLossS + m.deltaLossS) : "-"}` + ); + } + for (const m of ba.corners) { + if (!ab.corners.some((c: any) => Math.abs(c.corner.apexDistance - m.corner.apexDistance) <= 40)) { + console.log(`${pad("-", 10)} ${pad("-", 14)} | ${pad(m.corner.apexDistance.toFixed(0), 10)} ${pad(f(m.deltaLossS), 14)} | -`); + } + } + console.log(`totalDeltaS: (${a},${b}) ${ab.totalDeltaS} / (${b},${a}) ${ba.totalDeltaS} / soma ${(ab.totalDeltaS + ba.totalDeltaS).toFixed(2)}`); + } +} + +function resolution(): void { + for (const lap of LAPS) { + const s = tel(lap); + const dd: number[] = []; + const dt: number[] = []; + for (let i = 1; i < s.length; i++) { + dd.push(s[i].lapDistance - s[i - 1].lapDistance); + dt.push(s[i].sessionTime - s[i - 1].sessionTime); + } + const q = (xs: number[], p: number) => [...xs].sort((x, y) => x - y)[Math.floor(p * (xs.length - 1))]; + console.log( + `volta ${lap}: Δdist min ${q(dd, 0).toFixed(2)} mediana ${q(dd, 0.5).toFixed(2)} p95 ${q(dd, 0.95).toFixed(2)} máx ${q(dd, 1).toFixed(2)} m; ` + + `Δt min ${q(dt, 0).toFixed(3)} mediana ${q(dt, 0.5).toFixed(3)} p95 ${q(dt, 0.95).toFixed(3)} máx ${q(dt, 1).toFixed(3)} s` + ); + } +} + +function cut(): void { + const laps = LAPS.map((lap) => ({ + lapNumber: lap, + lapTimeMs: Math.round(lapTimeS(lap) * 1000), + telemetry: tel(lap).map((s: any) => ({ + lapDistance: s.lapDistance, sessionTime: s.sessionTime, speed: s.speed, throttle: s.throttle, brake: s.brake, steer: s.steer, + tyreSurfaceTemp: s.tyreSurfaceTemp, + })), + motion: mot(lap).map((s: any) => ({ lapDistance: s.lapDistance, x: s.worldPosition.x, z: s.worldPosition.z })), + })); + const json = JSON.stringify({ laps }); + console.log("campos: telemetria lapDistance, sessionTime, speed, throttle, brake, steer, tyreSurfaceTemp (4 rodas); movimento lapDistance, worldPosition.x, worldPosition.z"); + console.log(`JSON compacto: ${Buffer.byteLength(json)} bytes; gzip -9: ${zlib.gzipSync(json, { level: 9 }).length} bytes`); + const pretty = JSON.stringify({ laps }, null, 2); + console.log(`JSON indentado: ${Buffer.byteLength(pretty)} bytes`); + + // Precisão real: os campos do jogo chegam como float32 (ou inteiros); um double que é float32 exato satisfaz fround(x) === x. + const nums: Record = {}; + const walk = (o: any, p: string) => { + if (typeof o === "number") (nums[p] ??= []).push(o); + else if (o && typeof o === "object") for (const [k, v] of Object.entries(o)) walk(v, Array.isArray(o) ? p : p ? `${p}.${k}` : k); + }; + for (const l of laps) { + l.telemetry.forEach((s: any) => walk(s, "telemetry")); + l.motion.forEach((s: any) => walk(s, "motion")); + } + for (const [k, xs] of Object.entries(nums)) { + const f32 = xs.filter((x) => Math.fround(x) === x).length; + const ints = xs.filter((x) => Number.isInteger(x)).length; + console.log(` ${k}: ${xs.length} valores, float32 exatos ${f32}, inteiros ${ints}, mín ${Math.min(...xs).toFixed(3)}, máx ${Math.max(...xs).toFixed(3)}`); + } + const round = (decimals: Record) => + JSON.stringify({ laps }, (key, v) => (typeof v === "number" && key in decimals ? Number(v.toFixed(decimals[key])) : v)); + const sig9 = JSON.stringify({ laps }, (_k, v) => (typeof v === "number" && !Number.isInteger(v) ? Number(v.toPrecision(9)) : v)); + const back = JSON.parse(sig9); + let lossless = true; + JSON.parse(json).laps.forEach((l: any, i: number) => + l.telemetry.forEach((s: any, j: number) => { + for (const k of Object.keys(s)) if (typeof s[k] === "number" && Math.fround(back.laps[i].telemetry[j][k]) !== s[k]) lossless = false; + }) + ); + const domain = round({ lapDistance: 3, sessionTime: 4, throttle: 4, brake: 4, steer: 4, x: 3, z: 3 }); + for (const [name, text] of [["9 algarismos significativos (float32 sem perda)", sig9], ["por domínio: dist e posição 3 casas, tempo 4, pedais e volante 4", domain]] as const) { + console.log(`${name}: JSON ${Buffer.byteLength(text)} bytes; gzip -9 ${zlib.gzipSync(text, { level: 9 }).length} bytes`); + } + console.log(`9 algarismos devolve o float32 exato em toda a telemetria: ${lossless}`); +} + +// Mínimos locais da série suavizada (mesma suavização de cornerDetection.ts) com a subida de cada lado +// como o motor mede (sem parar num mínimo mais baixo) e como proeminência topográfica (parando nele). +function minima(): void { + const { movingAverage } = require(path.join(repo, "src/analysis/smoothing")); + for (const lap of LAPS) { + const s = tel(lap); + const sm: [number, number][] = movingAverage(s.map((x: any) => [x.lapDistance, x.speed]), 5); + console.log(`\nvolta ${lap}: mínimos locais da velocidade suavizada`); + console.log(" dist(m) vel(suav) subida esq./dir. (motor) subida esq./dir. (parando em mínimo mais baixo)"); + for (let i = 1; i < sm.length - 1; i++) { + const v = sm[i][1]; + if (!(v < sm[i - 1][1] && v <= sm[i + 1][1])) continue; + const rise = (dir: number, stopAtLower: boolean) => { + let r = 0; + for (let j = i + dir; j >= 0 && j < sm.length; j += dir) { + if (stopAtLower && sm[j][1] < v) break; + r = Math.max(r, sm[j][1] - v); + if (!stopAtLower && r >= 15) break; + } + return r; + }; + const engine = [rise(-1, false), rise(1, false)]; + const topo = [rise(-1, true), rise(1, true)]; + if (engine[0] < 15 || engine[1] < 15) continue; + console.log( + `${pad(sm[i][0].toFixed(1), 9)} ${pad(v.toFixed(1), 10)} ${pad(engine[0].toFixed(1), 10)} / ${pad(engine[1].toFixed(1), 6)} ${pad(topo[0].toFixed(1), 10)} / ${pad(topo[1].toFixed(1), 6)}` + ); + } + } +} + +// Freadas de cada volta: trechos contíguos com freio > 0.1 (o limiar do motor), e trechos sem acelerador pleno (< 0.9). +function events(): void { + for (const lap of LAPS) { + const s = tel(lap); + const runs = (pred: (x: any) => boolean) => { + const out: { from: number; to: number; peak: number; minSpeed: number }[] = []; + let cur: any = null; + for (const x of s) { + if (pred(x)) { + if (!cur) cur = { from: x.lapDistance, to: x.lapDistance, peak: x.brake, minSpeed: x.speed }; + cur.to = x.lapDistance; + cur.peak = Math.max(cur.peak, x.brake); + cur.minSpeed = Math.min(cur.minSpeed, x.speed); + } else if (cur) { + out.push(cur); + cur = null; + } + } + if (cur) out.push(cur); + return out; + }; + console.log(`\nvolta ${lap}: freadas (freio > 0.1)`); + for (const r of runs((x) => x.brake > 0.1)) console.log(` ${r.from.toFixed(1)}–${r.to.toFixed(1)} m, pico ${r.peak.toFixed(2)}, vel. mín. ${r.minSpeed}`); + console.log(`volta ${lap}: trechos sem acelerador pleno (< 0.9) com mais de 20 m`); + for (const r of runs((x) => x.throttle < 0.9).filter((r) => r.to - r.from > 20)) console.log(` ${r.from.toFixed(1)}–${r.to.toFixed(1)} m, vel. mín. ${r.minSpeed}`); + } +} + +// Em cada par, para cada volta: quantas vezes o início da freada que o motor usa é o próprio ápice, e em +// quantas dessas a volta tinha uma freada (freio > 0.1) terminando nos 150 m antes do ápice. +function atApex(): void { + for (const [a, b] of PAIRS) { + const cs = detectCorners(tel(a)); + for (const [role, lap] of [["principal", a], ["comparação", b]] as const) { + const s = tel(lap); + let same = 0; + let braked = 0; + const cases: string[] = []; + for (const c of cs) { + const ia = findApexIndexNear(s, c.apexDistance, 80); + if (ia === null) continue; + if (findBrakingStartIndex(s, ia, 0.1) !== ia) continue; + same++; + const apexD = s[ia].lapDistance; + let j = ia - 1; + while (j >= 0 && s[j].lapDistance >= apexD - 150 && s[j].brake <= 0.1) j--; + if (j >= 0 && s[j].lapDistance >= apexD - 150) { + let k = j; + while (k > 0 && s[k - 1].brake > 0.1) k--; + braked++; + cases.push(`ápice ${apexD.toFixed(1)}: freada ${s[k].lapDistance.toFixed(1)}–${s[j].lapDistance.toFixed(1)}`); + } + } + console.log(`(${a},${b}) ${role} (volta ${lap}): início = ápice em ${same} de ${cs.length}; com freada nos 150 m antes: ${braked}${cases.length ? " — " + cases.join("; ") : ""}`); + } + } +} + +// deltaLossS das curvas comuns medido nos dois sentidos sobre o MESMO trecho (a união dos dois trechos de hoje), +// com a série de delta de hoje: separa o efeito do trecho do efeito da série. +function lossesSameStretch(): void { + const { cornerLoss } = require(path.join(repo, "src/analysis/segmentDelta")); + for (const [a, b] of [[6, 7], [6, 8], [7, 8]]) { + const ab = insights(a, b); + const ba = insights(b, a); + const sAB = computeDelta(tel(a), tel(b)); + const sBA = computeDelta(tel(b), tel(a)); + console.log(`\n(${a},${b}) × (${b},${a}): trecho comum = união dos dois trechos de hoje`); + console.log("ápice(A,B) trecho hoje A,B / B,A / soma mesmo trecho A,B / B,A / soma"); + for (const c of ab.corners) { + const m = ba.corners.find((x: any) => Math.abs(x.corner.apexDistance - c.corner.apexDistance) <= 40); + if (!m) continue; + const from = Math.min(c.corner.brakingStartDistance, m.corner.brakingStartDistance); + const to = Math.max(c.corner.exitDistance, m.corner.exitDistance); + const x = cornerLoss(sAB, from, to); + const y = cornerLoss(sBA, from, to); + console.log( + `${pad(c.corner.apexDistance.toFixed(0), 10)} ${pad(from.toFixed(0), 6)}–${to.toFixed(0).padEnd(6)} ${pad(f(c.deltaLossS), 6)} / ${pad(f(m.deltaLossS), 5)} / ${pad(f(c.deltaLossS + m.deltaLossS), 5)} ${pad(f(x), 6)} / ${pad(f(y), 5)} / ${pad(f(x + y), 5)}` + ); + } + } +} + +// Total com o início de volta dado pelo jogo (sessionTime − currentLapTimeInMS na série crua), na última distância comum. +function deltaGameStart(rawPath: string): void { + const raw = JSON.parse(require("node:fs").readFileSync(rawPath, "utf8")) as any[]; + const start = new Map(); + for (const lap of LAPS) { + const xs = raw.filter((r) => r.packetId === 2 && r.lapNum === lap).map((r) => r.sessionTime - r.currentLapTimeInMS / 1000).sort((p, q) => p - q); + start.set(lap, xs[Math.floor(xs.length / 2)]); + } + console.log(`início pelo jogo: ${LAPS.map((l) => `volta ${l} ${start.get(l)!.toFixed(3)}`).join(", ")}`); + console.log(`atraso da primeira amostra gravada: ${LAPS.map((l) => `volta ${l} ${(tel(l)[0].sessionTime - start.get(l)!).toFixed(3)} s`).join(", ")}`); + console.log("par oficial alt3(início do jogo, última distância comum) erro"); + for (const [a, b] of PAIRS) { + const sa = tel(a); + const sb = tel(b); + const dEnd = Math.min(sa[sa.length - 1].lapDistance, sb[sb.length - 1].lapDistance); + const v = (elapsedAt(sa, start.get(a)!, dEnd) ?? NaN) - (elapsedAt(sb, start.get(b)!, dEnd) ?? NaN); + const official = lapTimeS(a) - lapTimeS(b); + console.log(`(${a},${b}) ${pad(official.toFixed(3), 7)} ${pad(v.toFixed(3), 44)} ${pad((v - official).toFixed(3), 6)}`); + } +} + +const [cmd, ...args] = process.argv.slice(2); +if (["minima", "events", "atApex", "lossesSameStretch", "deltaGameStart"].includes(cmd)) { + if (cmd === "minima") minima(); + else if (cmd === "events") events(); + else if (cmd === "atApex") atApex(); + else if (cmd === "lossesSameStretch") lossesSameStretch(); + else deltaGameStart(args[0]); + process.exit(0); +} +if (cmd === "corners") corners(); +else if (cmd === "window") windowCmd(Number(args[0]), Number(args[1]), Number(args[2])); +else if (cmd === "delta") delta(); +else if (cmd === "losses") losses(); +else if (cmd === "resolution") resolution(); +else if (cmd === "cut") cut(); +else throw new Error(`comando desconhecido: ${cmd}`); diff --git a/specs/005-analysis-engine-artifacts/evidence/rawChecks.ts b/specs/005-analysis-engine-artifacts/evidence/rawChecks.ts new file mode 100644 index 0000000..c479635 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/evidence/rawChecks.ts @@ -0,0 +1,88 @@ +// Uso: node -r ts-node/register rawChecks.ts [volta de até] +// Sem argumentos extras: sinais de flashback e out-lap na série crua (todos os pacotes 2 e 6 do carro do jogador). +// Com "volta de até": a série crua de telemetria (pacote 6) daquela volta entre duas distâncias. +import fs from "node:fs"; + +const [rawPath, lapArg, fromArg, toArg] = process.argv.slice(2); +const raw = JSON.parse(fs.readFileSync(rawPath, "utf8")) as any[]; +const pad = (s: string, n: number) => s.padStart(n); + +if (lapArg) { + const lap = Number(lapArg); + const rows = raw.filter((r) => r.packetId === 6 && r.lapNum === lap && r.lapDistance >= Number(fromArg) && r.lapDistance <= Number(toArg)); + console.log(`volta ${lap}, ${fromArg}–${toArg} m, série crua (${rows.length} pacotes de telemetria)`); + console.log(" dist(m) vel freio acel volante sessionTime frame overallFrame"); + for (const r of rows) { + console.log( + `${pad(r.lapDistance.toFixed(1), 9)} ${pad(String(r.speed), 4)} ${pad(r.brake.toFixed(2), 6)} ${pad(r.throttle.toFixed(2), 5)} ${pad(r.steer.toFixed(2), 8)} ${pad(r.sessionTime.toFixed(3), 12)} ${pad(String(r.frame), 6)} ${pad(String(r.overallFrame), 13)}` + ); + } + process.exit(0); +} + +const lapRows = raw.filter((r) => r.packetId === 2); +console.log(`pacotes de volta (2): ${lapRows.length}; de telemetria (6): ${raw.filter((r) => r.packetId === 6).length}`); +console.log(`primeiro pacote: t=${raw[0].sessionTime.toFixed(3)} frame=${raw[0].frame} overall=${raw[0].overallFrame}`); + +// Trechos contíguos por (volta, driverStatus, pitStatus) +let seg: any = null; +const segs: any[] = []; +for (const r of lapRows) { + const key = `${r.lapNum}|${r.driverStatus}|${r.pitStatus}`; + if (!seg || seg.key !== key) { + seg = { key, lapNum: r.lapNum, driverStatus: r.driverStatus, pitStatus: r.pitStatus, first: r, last: r, n: 0, minDist: r.lapDistance, maxDist: r.lapDistance }; + segs.push(seg); + } + seg.last = r; + seg.n++; + seg.minDist = Math.min(seg.minDist, r.lapDistance); + seg.maxDist = Math.max(seg.maxDist, r.lapDistance); +} +console.log("\ntrechos contíguos dos pacotes de volta (driverStatus: 1 volta lançada, 2 in lap, 3 out lap, 4 na pista)"); +console.log("volta driverStatus pitStatus pacotes t início t fim dist início dist fim dist mín dist máx"); +for (const s of segs) { + console.log( + `${pad(String(s.lapNum), 5)} ${pad(String(s.driverStatus), 13)} ${pad(String(s.pitStatus), 10)} ${pad(String(s.n), 8)} ${pad(s.first.sessionTime.toFixed(3), 9)} ${pad(s.last.sessionTime.toFixed(3), 9)} ${pad(s.first.lapDistance.toFixed(1), 12)} ${pad(s.last.lapDistance.toFixed(1), 9)} ${pad(s.minDist.toFixed(1), 9)} ${pad(s.maxDist.toFixed(1), 9)}` + ); +} + +// Regressões dentro de uma volta: sessionTime, frameIdentifier, lapDistance; divergência frame vs overallFrame +let prev: any = null; +let timeBack = 0; +let frameBack = 0; +let distBack = 0; +const distBackSamples: string[] = []; +const offsets = new Set(); +for (const r of raw) { + offsets.add(r.overallFrame - r.frame); + if (prev) { + if (r.sessionTime < prev.sessionTime) timeBack++; + if (r.frame < prev.frame) frameBack++; + if (r.packetId === 2 && prev.lapNum === r.lapNum && r.lapDistance < prev.lapDistance - 1) { + distBack++; + if (distBackSamples.length < 10) distBackSamples.push(`volta ${r.lapNum}: ${prev.lapDistance.toFixed(1)} → ${r.lapDistance.toFixed(1)} m em t=${r.sessionTime.toFixed(3)}`); + } + } + if (r.packetId === 2) prev = r; + else prev = { ...r, lapNum: prev?.lapNum, lapDistance: prev?.lapDistance }; +} +console.log(`\nsessionTime voltando: ${timeBack}; frameIdentifier voltando: ${frameBack}; lapDistance voltando (>1 m, mesma volta): ${distBack}`); +for (const s of distBackSamples) console.log(` ${s}`); +console.log(`overallFrame − frame: ${[...offsets].join(", ")}`); + +// Início de cada volta segundo o jogo: sessionTime − currentLapTimeInMS +console.log("\ninício de volta segundo o jogo (sessionTime − currentLapTimeInMS/1000), mediana por volta, e lastLapTimeInMS ao trocar de volta"); +const byLap = new Map(); +for (const r of lapRows) { + if (!byLap.has(r.lapNum)) byLap.set(r.lapNum, []); + byLap.get(r.lapNum)!.push(r.sessionTime - r.currentLapTimeInMS / 1000); +} +for (const [lap, xs] of byLap) { + const sorted = [...xs].sort((a, b) => a - b); + console.log(`volta ${lap}: início ${sorted[Math.floor(sorted.length / 2)].toFixed(3)} (mín ${sorted[0].toFixed(3)}, máx ${sorted[sorted.length - 1].toFixed(3)})`); +} +for (let i = 1; i < lapRows.length; i++) { + if (lapRows[i].lapNum !== lapRows[i - 1].lapNum) { + console.log(`troca ${lapRows[i - 1].lapNum}→${lapRows[i].lapNum} em t=${lapRows[i].sessionTime.toFixed(3)}: lastLapTimeInMS=${lapRows[i].lastLapTimeInMS}, dist ${lapRows[i - 1].lapDistance.toFixed(1)} → ${lapRows[i].lapDistance.toFixed(1)}`); + } +} diff --git a/specs/005-analysis-engine-artifacts/plan.md b/specs/005-analysis-engine-artifacts/plan.md new file mode 100644 index 0000000..962fb63 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/plan.md @@ -0,0 +1,210 @@ +# Implementation Plan: Fix the analysis engine's artifacts + +**Branch**: `005-analysis-engine-artifacts` | **Date**: 2026-10-25 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/005-analysis-engine-artifacts/spec.md`; investigation and design research in [research.md](research.md). + +## Summary + +The investigation (research.md, sections 0–9) confirmed every observation as an engine artifact, partly mixed with real driving: the delta series takes each lap's first and last stored samples as its start and end; corners are detected on the primary lap only, with a prominence test that passes 1–10 km/h throttle lifts; ±80 m windows let neighbouring corners share a point; the backward walk for the braking start stops at the apex when the brake was released before it; the forward walk for the throttle pick-up has no bound. The out-lap and a flashback were excluded. + +The design makes a corner **a place on the track**: corners come from the track's geometry (curvature of the two laps' mean path), so (A,B) and (B,A) have the same set; each corner owns a contiguous stretch, bounded at the fastest point between it and its neighbours; each lap's behaviour (slowest point, braking start, full-throttle pick-up) is measured inside that stretch, with "did not brake" and "no full throttle" as facts distinct from "unknown". The delta series counts each lap from its start extrapolated to 0 m and evaluates both laps on the same distances, so `totalDeltaS` agrees with the official difference within 0.021 s and every difference is exactly antisymmetric. A cached analysis is valid only while its insights match freshly computed ones (`insightsMatch`, brought from spec 003). A versioned cut of the recording carries the properties into the CI. + +## Technical Context + +**Language/Version**: TypeScript (strict, CommonJS) on Node.js 22 (`.nvmrc`, ≥ 22.17) + +**Primary Dependencies**: none new; `better-sqlite3` only in the fixture tool and the existing repositories + +**Storage**: SQLite, unchanged schema (no migration: the insights comparison happens at read time against the row's stored `insights_json`) + +**Testing**: `node:test` + `node:assert/strict` via `ts-node/register`; synthetic fixtures in `src/analysis/testFixtures.ts`; a versioned recording cut (JSON) for the property tests + +**Target Platform**: the existing server and CLI tools + +**Project Type**: web service (REST/WebSocket) with pure analysis library + +**Performance Goals**: an analysis request already reads both laps' samples on a miss; now it reads them on every request to compare insights (about 1,600 telemetry and 1,600 motion rows, plus the pure computation; measured in the implementation, no target beyond today's interactive use) + +**Constraints**: `src/analysis/` pure and deterministic; no paid call; no access to `data/telemetry.db`; recording cut under its ceiling + +**Scale/Scope**: one engine, its contract, the coach's reading and prompt, the cache staleness rule, documentation + +## Constitution Check + +| Principle | Status | +|---|---| +| I. Validated binary parsing | Not touched. | +| II. Ingestion never crashes; config fails fast | Not touched; the analysis is not on the ingestion path. | +| III. Deterministic analysis; the AI only explains | Kept: geometry, stretches and behaviour are pure functions of the samples; the coach still receives only `LapInsights` plus the code's reading. The cached-analysis rule keeps paid calls to one per pair and input (a call happens only when the insights actually changed). | +| IV. Record what is known; never invent | Strengthened: the invented 100–190 m differences become `null` with a boolean saying what happened; the authoritative lap times stay the reference for `totalDeltaS`. | +| V. Simple, embedded infrastructure | No new dependency, no schema change. | +| VI. This repository owns the API contract | Contract changes listed in [contracts/insights.md](contracts/insights.md) and in "Impact" below, for the frontend spec. | +| Workflow (SDD, tests, CI gate, commits, limitations) | Spec, plan and tasks committed before code; `npm test` and `npm run typecheck` before each commit; Known limitations updated in README and `CLAUDE.md` together. The recording cut is an exception to "recordings stay out of git", recorded in `CLAUDE.md` (Rules) and the README (FR-015c); the rule is not in the constitution, so no amendment. | + +Gate: **passes**, with the fixture exception recorded where the rule is written. Re-checked after the design: no change. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-analysis-engine-artifacts/ +├── spec.md +├── plan.md # this file +├── research.md # Phase 0 (sections 0–9) and design research (10–15) +├── data-model.md # corner, stretch, behaviour, insights +├── contracts/ +│ ├── insights.md # LapInsights, field by field, before and after +│ └── coach-reading.md # the coach's prompt and reading changes +├── quickstart.md # how to verify the six pairs and the cut +├── evidence/ # investigation scripts (not run by npm test) +│ ├── buildResearchDb.ts +│ ├── inspect.ts +│ ├── rawChecks.ts +│ └── cornerOptions.ts +├── checklists/requirements.md +└── tasks.md # /speckit-tasks +``` + +### Source Code (repository root) + +```text +src/analysis/ +├── trackGeometry.ts # NEW (pure): mean path of two laps on a 5 m grid, curvature, geometric corners +├── cornerDetection.ts # REWRITTEN: corners from trackGeometry + stretches (partition at the fastest mean point); hasProminence removed +├── cornerBehaviour.ts # NEW (pure): per lap, inside a stretch: slowest point, braking start or none, full-throttle pick-up or none +├── sampleSearch.ts # findApexIndexNear, findBrakingStartIndex, findThrottleOnIndex replaced by cornerBehaviour; peakInRange kept +├── segmentDelta.ts # computeDelta: start extrapolated to 0 m, both laps on the same distances, common range; cornerLoss interpolates +├── brakingAnalysis.ts # stretch-based; brakedPrimary/brakedComparison +├── throttleAnalysis.ts # stretch-based; fullThrottlePrimary/fullThrottleComparison; coasting from the slowest point +├── buildInsights.ts # orchestration; totalDeltaS at the last common distance +├── types.ts # new fields, documented meanings +├── recordingCutFormat.ts # NEW (pure): writes and reads the cut (only the fields the engine reads, 9 significant digits) +├── recordingCut.json # NEW: the versioned cut (laps 6–8), generated +└── recordingCut.test.ts # NEW: the cut's identity and size ceiling, then the property tests each engine phase adds +src/coaching/ +├── insightsMatch.ts # NEW: from 003-demo-mode:src/demo/insightsMatch.ts, unchanged, with its tests +├── analysisStaleness.ts # current = same prompt version, same model, insights match +├── analysisResolver.ts # computes the pair's insights before deciding; stale → regenerate or 503 +├── coachPrompt.ts # field descriptions updated (contracts/coach-reading.md) +├── coachReading.ts # "did not brake" / "no full throttle" phrases +└── promptVersion.ts # REFERENCE_INPUTS gain the new fields +tools/ +└── exportRecordingCut.ts # NEW: recording → temporary DB → recordingCut.json, records the recording's SHA-256 +README.md, CLAUDE.md # engine description, Known limitations, fixture exception +``` + +## Design decisions (from research) + +| # | Decision | Research | +|---|---|---| +| D1 | **Corners from the track's geometry** (option B): the two laps' `worldPosition` (x, z) interpolated on a common 5 m grid and averaged; heading and curvature, smoothed over 35 m (7 grid points); a corner is a run of same-sign curvature with radius ≤ 100 m and turning angle ≥ 20°; its apex is the point of highest curvature. Symmetric by construction: 8 places in all three pairs, and in the three pairs of laps 1–3 of a local recording of the same session. `hasProminence` is no longer used for detection and is removed. | §10 | +| D2 | **The corner's stretch is a partition**: consecutive corners split at the highest mean speed of the two laps between their apexes; the first starts at the start of the common range, the last ends at its end. `brakingStartDistance` and `exitDistance` carry the stretch's bounds (always numbers). | §10.1 | +| D3 | **Behaviour per lap inside the stretch**: the lap's slowest sample; its braking start = the first sample of the last braking phase (brake > 0.1) that ends before the slowest sample, or none; its pick-up = the first sample at throttle ≥ 0.9 after the slowest sample, or none. Peak brake over that braking phase (0 when none). Trail braking and coasting measured from the lap's own points. | §4, §10.2 | +| D4 | **"Did not brake" is a fact** (option F): `braking.brakedPrimary/brakedComparison` and `throttle.fullThrottlePrimary/fullThrottleComparison`, `boolean \| null` (`null` only when the lap has no samples in the stretch). `brakingPointDiffM` is `null` unless both laps braked; `throttleOnDiffM` unless both reached full throttle. | §11 | +| D5 | **Delta series, alternative 2**: each lap's elapsed time from its start extrapolated to 0 m from its first two samples; the series evaluated at the primary lap's sample distances inside the range both laps cover, with both laps interpolated; `totalDeltaS` at the last common distance; `cornerLoss` interpolates both laps at the stretch's bounds. Alternative 3 rejected. `computeDelta` implements this series (checked 2026-10-25) and stays in `segmentDelta.ts` as the reference port for the frontend spec; the engine itself uses `totalDelta` and `cornerLoss`. | §2.3, §12 | +| D6 | **Tolerances**: same corners identical; differences and losses antisymmetric within 0.01 + 1e-9; `totalDeltaS` within 0.045 s of the official difference. | §14 | +| D7 | **One staleness rule**: a cached analysis is current when its prompt version and model are the current ones and `insightsMatch(stored, computed)` holds; otherwise regenerate with the key, 503 without; a pair whose laps lost their samples answers as today's "no samples". `insightsMatch` is brought unchanged to `src/coaching/insightsMatch.ts`, the commit naming `003-demo-mode:src/demo/insightsMatch.ts` as its origin. | spec FR-018 | +| D8 | **Recording cut**: the stored samples of laps 6–8 with only the fields the engine reads, numbers written with 9 significant digits (lossless for float32), plus each lap's number, official time and validity and the recording's SHA-256; generated by `tools/exportRecordingCut.ts` from the published recording through a temporary database. Measured 600,523 bytes in the research, 643,689 bytes as committed (the nested `worldPosition`; tasks T009, research §13 note); **ceiling 660,000 bytes** (10% margin), enforced by a test. | §13 | +| D9 | **Severity and `apexSpeed`** (approved): `apexSpeed` stays the primary lap's minimum speed in the stretch (behaviour, documented); `severity` is classified on the mean of the two laps' minimum speeds, so it is the same in both directions. | data-model.md | + +## Options (decided by the maintainer on 2026-10-25) + +Each with the recommendation first. **Decisions: O1 B, O2 partition, O3 F, O4 no corners, O5 keep the names; D9 approved.** With them: + +- The geometry thresholds (radius 100 m, turning angle 20°, smoothing 35 m, grid 5 m) become named constants in `src/analysis/trackGeometry.ts`, each with the reason for its value (research §10). +- Known limitations in README and `CLAUDE.md`, in the same commit as the behaviour: the thresholds were validated on Interlagos only, and on a track whose fast corners have a radius above 100 m those corners may not appear; a lap without motion samples gets no corners. +- Future work, recorded: revalidate the thresholds when a recording of another track exists. +- Future work, recorded (M2, 2026-10-25): the request of the coach is assembled inline in `coachService.ts` (`generateCoaching`), so the token count of the system block had to repeat its shape in a script outside the repository. The spec that chooses the coach's model should extract a function that builds the coach's request, receiving the model, used by `coachService` and by the evaluation. +- Debt for the frontend spec, recorded: renaming `brakingStartDistance`, which now means the start of the corner's stretch (and `exitDistance`, its end). + +**O1 — How corners are found** (FR-013). Recommended **B, geometry** (D1). + +| Option | For | Against | +|---|---|---| +| **B** geometry of the mean path | a corner is literally a place; same 8 places in every pair seen; T1 and T2 two corners whoever braked; a corner where a lap only lifted still exists | thresholds (100 m, 20°, 35 m smoothing) validated on Interlagos only; needs motion samples | +| A detect on both laps, merge apexes | uses speed only | counts T1 twice (334 and 391 m); count depends on the merge distance (10 / 9 / 8) | +| C minima of the mean speed | uses speed only, symmetric | a corner where only one lap slowed may vanish (6–7 loses T2 and ~2,496 m) | + +**O2 — The stretch** (FR-009). Recommended **partition** (D2). + +| Option | For | Against | +|---|---|---| +| **Partition** at the fastest mean point | contiguous, no overlap, numeric; losses add up to the total; behaviour always inside | a corner's loss includes the straight after it; the frontend's highlight becomes wider | +| Trimmed to "both laps flat" | the highlight covers only the corner | breaks the measurements: flat corners collapse, braking cut out, most throttle differences `null` (§10.1) | + +**O3 — "Did not brake"** (spec, decision 2). Recommended **F, booleans** (D4). + +| Option | Contract | Prompt / reading | Frontend | Demo seed | +|---|---|---|---|---| +| **F** booleans per lap | 4 additive fields | 2 new phrase families, field descriptions | types only (no UI reads braking today) | regenerated anyway | +| N only `null` | none | none | none | regenerated anyway | +| S status per difference | 2 fields replaced (breaking) | one phrase per status | types change, renamed fields | regenerated anyway | + +**O4 — A lap without motion samples.** Recommended **no corners** (`corners: []`, `totalDeltaS` and tyre windows still computed), documented as a limitation: motion and telemetry are stored together with identical counts in every recording read (§10.3), and a second definition doubles the tests. Alternative: fall back to option C. + +**O5 — Field names of the bounds.** Recommended **keep `brakingStartDistance` / `exitDistance`**, with their new meaning (the stretch's bounds) documented in the contract, and record the rename as debt for the frontend spec: renaming now breaks the frontend's highlight the moment this merges, while the frontend spec has to happen anyway (FR-020). Alternative: rename to `stretchStartDistance` / `stretchEndDistance` now, a breaking contract change shipped with the frontend spec. + +## Impact + +### Contract (REST `POST /sessions/:id/analysis`, `insights`) — for the frontend's `src/types/rest.ts` + +Details in [contracts/insights.md](contracts/insights.md). Shape: four **added** fields (O3-F); no field removed or renamed (O5). Meaning: `totalDeltaS` now agrees with the official difference; `corner.apexDistance` is the geometric apex (same in both directions); `corner.brakingStartDistance`/`exitDistance` are the stretch's bounds; `corner.severity` from both laps; `deltaLossS` over the stretch (sums to the total); the difference fields are `null` when either lap did not brake / did not reach full throttle. The response's other keys (`analysis`, `cached`) do not change; the status codes change in two ways, both already handled by the frontend: a pair that was served from the cache can now answer 503 without the key (its insights changed), and a pair without samples answers 400 (no samples) whatever the cache holds or the key configuration, where an uncached one without the key answered 503 (contracts/insights.md, "HTTP status change"). + +### Prompt and coach reading (spec 004's code) + +Details in [contracts/coach-reading.md](contracts/coach-reading.md): the `totalDeltaS` line (no longer "not the official difference", still "use the official difference of the message"); `corners` described as the track's corners, the same in both directions; `brakingStartDistance`/`exitDistance` as the corner's stretch; `deltaLossS` including the exit onto the next straight; the four booleans; coasting from the lap's slowest point. The reading adds phrases for "did not brake (only lifted)" and "did not reach full throttle before the next corner"; the opportunity labels keep their rule (the numbers change because `apexDistance` and `deltaLossS` change). + +### `PROMPT_VERSION` + +**Changes**, through three declared causes: the system prompt text, the reading's new phrases, and `REFERENCE_INPUTS` gaining the four fields (the reference inputs are typed `LapInsights`). A test keeps showing that a change of engine values alone (same prompt, same builder, same reference inputs) leaves it unchanged (SC-007). Every row of `lap_analyses` and every entry of the demo seed becomes stale through the version, independently of D7. + +### Frontend (a separate spec, not changed here) + +- **Delta chart**: its copy is identical to today's backend (research §7) and already shows the error (0.28 vs 0.162 s in (6,7); 0.13 s off in (8,7), which the demo shows). It must port D5 — or, the alternative for that spec, receive the series from the API and drop the copy (a contract change, decided there together with the opportunity rule's way out). **Blocks publishing the demo** (FR-020). +- **Types**: four optional boolean fields; documented meanings of `apexDistance`, the bounds, `totalDeltaS`. +- **Visible changes without code changes**: the chips' distances and losses (`apexDistance`, `deltaLossS`), the corner markers (`SessionAnalysisPage.tsx:164`), and the highlight (`:170`), which becomes the whole stretch. +- Debt recorded for that spec: the bounds' names (O5). + +### The demo's seed file (spec 003) + +All six entries become stale (prompt version and insights). After this spec merges, spec 003, when updated with `main`, imports `src/coaching/insightsMatch.ts` instead of its copy, regenerates the seed from the corrected insights (its existing export, `--dry-run` first), and the maintainer reads the new texts. The seed's format does not change. The publishing dependency on the frontend spec (FR-020) is written into spec 003 in this feature's last step (SC-007a; see "Steps on another branch"). + +## Commit sequence (proposed; tasks.md will refine) + +tasks.md refines it in one point, approved by the maintainer: the recording cut (item 7 below) comes right after `insightsMatch`, before the engine changes, so every property checked on the cut is seen failing against the previous phase's engine before the change that makes it true. + +1. `docs:` spec update, research, plan, data model, contracts, quickstart, evidence scripts. +2. `docs:` tasks. +3. `feat:` `insightsMatch` on `main` (origin named in the message), with its tests. +4. `test:`/`feat:` delta series (D5) with symmetry and official-difference tests on synthetic laps. +5. `feat:` track geometry and corners (D1, D2), synthetic tests (two close corners, one lap lifting, flat corner). +6. `feat:` behaviour per stretch, braking and throttle analyses, booleans (D3, D4); `buildInsights`. +7. `feat:` recording cut tool and fixture, property tests and size ceiling (D8). +8. `feat:` coach prompt and reading, reference inputs (prompt version changes). +9. `feat:` staleness rule with insights (D7) in the resolver. +10. `docs:` README and `CLAUDE.md` (engine, Known limitations, fixture exception), research "after" for the six pairs. +11. Steps on another branch (below). + +## Steps on another branch (approved for this feature) + +Near the end: switch to `003-demo-mode` with a clean working tree, make one `docs:` commit in spec 003 recording the publishing dependency of the "Delta acumulado" chart (FR-020), and stop before pushing. That branch already has a local commit not yet pushed (`0e63e48`), which goes with that push. This becomes a task in tasks.md. + +## Manual tasks of the maintainer + +None on `data/telemetry.db`: there is no schema change. Reading the six pairs' new insights (quickstart) before spec 003 regenerates the seed. + +## Out of scope, recorded + +- The frontend (chart, types, highlight, field names). +- The demo mode and its seed (spec 003). +- Flashback and out-lap (their spec); not present in the published recording. +- Validating the geometry thresholds on another track (no recording available): a Known limitation. + +## Complexity Tracking + +| Item | Why | Simpler alternative rejected because | +|---|---|---| +| A 600 KB JSON fixture in git | the six-pair properties (symmetry, total vs official, same corners) need real whole laps in the CI | synthetic laps do not reproduce the cases found (§3–§6); downloading the recording in the CI adds network and 25 MB per run (spec 003, research §11) | +| Geometry from motion | only a lap-independent definition makes the corner set the same both ways | speed-based options fail on the data (§10) | diff --git a/specs/005-analysis-engine-artifacts/quickstart.md b/specs/005-analysis-engine-artifacts/quickstart.md new file mode 100644 index 0000000..612a73b --- /dev/null +++ b/specs/005-analysis-engine-artifacts/quickstart.md @@ -0,0 +1,41 @@ +# Quickstart: verifying the corrected engine + +No paid call; `data/telemetry.db` is never opened. Every command that loads the dotenv configuration runs with `DOTENV_CONFIG_PATH` pointing at a `.env` with no key and no ports, outside the repository, after this check printed `false`: + +```bash +DOTENV_CONFIG_PATH=/empty.env node -r dotenv/config -e "console.log(Boolean(process.env.ANTHROPIC_API_KEY))" +``` + +## A. Automated + +```bash +npm test # synthetic property tests + the recording cut's property tests and size ceiling +npm run typecheck +``` + +Expected: all pass with no key and no database. The cut's tests check, for (6,7), (6,8), (7,8) and their reverses: the same corners both ways; every difference and `deltaLossS` antisymmetric within 0.01 + 1e-9; `totalDeltaS` within 0.045 s of the official difference; no two corners sharing a sample; the file under 660,000 bytes. + +## B. The six pairs from the published recording + +1. Download the recording (README, "sample-recording-v1") and check its SHA-256 is `c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033`. +2. Build a temporary database (`DB_PATH` in a scratch folder): + + ```bash + DB_PATH=/q005.db TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register \ + specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts recordings/demo-interlagos-tt-20hz-laps6-8.jsonl /raw.json + ``` + + Expected: 1 session, laps 6–8 with 74,306 / 74,144 / 73,372 ms, 2,411 car telemetry and 2,411 motion samples. +3. For each pair, `DB_PATH=/q005.db npm run analyze -- 1 ` and record the insights in research.md ("after"), next to the "before" of section 2–6. Expected: eight corners in every pair, the same in both directions; T1's braking differences in the tens of metres, not 180–190; `null` with a `false` boolean where a lap only lifted (T2, ~2,120 m, ~2,490 m). +4. Read the coach messages printed by `analyze` for (8,6) and (8,7), the pairs the demo shows, before spec 003 regenerates the seed. + +## C. Regenerating the cut + +```bash +DB_PATH=/cut.db node -r ts-node/register tools/exportRecordingCut.ts recordings/demo-interlagos-tt-20hz-laps6-8.jsonl +git diff --stat src/analysis/recordingCut.json # expected: no change +``` + +## D. The cache rule + +With a temporary database holding a cached row whose insights differ from the recomputed ones: without the key, `POST /sessions/1/analysis` answers 503; covered by the resolver's tests with a fake coach (no manual paid check). diff --git a/specs/005-analysis-engine-artifacts/research.md b/specs/005-analysis-engine-artifacts/research.md new file mode 100644 index 0000000..7fe5072 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/research.md @@ -0,0 +1,738 @@ +# Research: Fix the analysis engine's artifacts + +**Feature**: [spec.md](spec.md) | **Phases**: 0 (investigation, sections 0–9, 2026-10-24, verdicts approved on 2026-10-25) and 1 (design research, sections 10–15, 2026-10-25) | **Code read**: `main` at `7dc6685`; frontend `main` at `9334f87` (read only) + +Sections 0–9 are the investigation that FR-001–FR-005 and FR-011a require before any correction; sections 10–15 are the research for the design, after the maintainer's decisions. Every number below was produced by the scripts in [evidence/](evidence/) on a temporary database built from the published recording (section 10 also reads a local recording of the same session, said where); the commands are in section 0 so the maintainer can re-run them. The decisions themselves are in [plan.md](plan.md). + +Verdict vocabulary (FR-002): **engine artifact** (the code produces a value the samples do not show), **real on track** (the samples show it), **decimation effect** (the value comes from the 4 m / 500 ms decimation), **undeterminable** (the data cannot tell). + +## Summary of verdicts + +| # | Observation | Verdict | Main evidence | +|---|---|---|---| +| 1 | `totalDeltaS` not symmetric, not the official difference | **Engine artifact** | Each lap's start is its own first stored sample (0.004 / 0.028 / −0.015 s off the game's lap start) and the comparison lap is clamped at its own last sample (lap 7 ends at 4,286.8 m, laps 6 and 8 at 4,294 m): (6,7) = 74.285 − 74.005 = **0.280** (section 2) | +| 1a | `deltaLossS` not symmetric corner by corner | **Engine artifact** | The stretch is the primary lap's own braking start → throttle pick-up, so (A,B) and (B,A) measure different stretches; on the same stretch the two directions are opposite within 0.05 s, mostly 0.01 (section 2.4) | +| 2 | Neighbouring corners share a point (±80 m windows) | **Engine artifact** | (6,7) corners at 334 and 399 m both use lap 6's sample at 333.7 m and lap 7's at 391.2 m; corner 492's "apex" in lap 7 is 416.3 m, the first sample of its window, where lap 7 is accelerating (section 3) | +| 3 | Braking start stops at the apex | **Engine artifact**, plus **real on track** where a lap did not brake | Lap 7 braked at T1 from 227.9 to 365.5 m, but the engine reports its braking start at the apex, 391.2 m; the real braking points of laps 6 and 7 are 16.7 m apart, the engine says 179.97 m. Where the comparison lap did not brake at all (lap 7 and 8 near 2,050–2,150 m), a braking-point difference does not exist (section 4) | +| 4 | The same throttle pick-up for neighbouring corners | **Real on track** (no full throttle between those corners) with an **engine artifact** in the attribution | Lap 6 stays below 0.9 throttle from 211.2 to 492.3 m; the engine gives the same 497.8 m pick-up to the corners at 334, 399 and 492 m because the forward search has no bound (section 5) | +| 5 | 12 corners with lap 6 primary, 7 otherwise | **Not the out-lap, not a flashback.** Five of the twelve are an **engine artifact** (the prominence test); the remaining difference in *which* corners lap 6 has is **real on track** | No regression of time, frame or distance, constant `overallFrame − frame`, `driverStatus` 1 throughout. The prominence test does not stop at a deeper minimum, so 1–10 km/h dips pass a 15 km/h test; with a true prominence each lap gives 7 corners, but lap 6's set differs at T1–T2 and near 2,496 m (section 6) | + +None of the five is a decimation effect at the points that decide them (section 1.2). Consequences for the spec are in section 9: the condition under which the clarify session accepted lap 6's extra corners as a recorded reason (the out-lap or a flashback) **is not met**, so they are this spec's subject (FR-013, in full). + +--- + +## 0. How it was built + +Environment for every command below: `DOTENV_CONFIG_PATH` pointing at an empty `.env` in the agent's scratch folder (no key, no ports), checked first: + +```text +$ DOTENV_CONFIG_PATH=/empty.env node -r dotenv/config -e "console.log(Boolean(process.env.ANTHROPIC_API_KEY))" +false +``` + +`DB_PATH` pointed at a new file in the scratch folder; `data/telemetry.db` was never opened. The scripts set nothing else and make no network call. + +```text +$ DB_PATH=/r005/research.db TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register \ + specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts recordings/demo-interlagos-tt-20hz-laps6-8.jsonl /r005/raw.json +sha256 da gravação: c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033 +banco: \r005\research.db +pacotes lidos: 13960; descartados: 0 +sessões: [{"id":1,"session_uid":"1026725433292396341","track_id":16}] +voltas: [{"lap_number":6,"lap_time_ms":74306,"is_valid":0},{"lap_number":7,"lap_time_ms":74144,"is_valid":1},{"lap_number":8,"lap_time_ms":73372,"is_valid":1}] +car_telemetry_samples: [{"lap":6,"n":797},{"lap":7,"n":801},{"lap":8,"n":793},{"lap":9,"n":20}] +motion_samples: [{"lap":6,"n":797},{"lap":7,"n":801},{"lap":8,"n":793},{"lap":9,"n":20}] +série crua: 8916 linhas em \r005\raw.json +``` + +The hash is the published one (`003-demo-mode:specs/003-demo-mode/research.md`, section 2; spec 004 research, section 4) and the counts are the 2,411 + 2,411 of a 1x replay (FR-005). `buildResearchDb.ts` passes every packet through `dispatchPacket` and `persistTelemetry` as `src/server.ts:31-90` does, with no socket, and also writes the player car's raw series (every lap-data and car-telemetry packet, 4,458 of each, before decimation) for the decimation and flashback checks. + +The readings: + +```text +inspect.ts corners | window | events | atApex | minima | delta | deltaGameStart | losses | lossesSameStretch | resolution | cut +rawChecks.ts # flashback / out-lap signals +rawChecks.ts # raw (undecimated) car telemetry between two distances +cornerOptions.ts | cornerOptions.ts design [STRETCH=open] | cornerOptions.ts places ... # design prototypes (sections 10–12) +``` + +All run as `DB_PATH=/r005/research.db TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register specs/005-analysis-engine-artifacts/evidence/