From 1aad2731d10b46050d8ef8acd12a1c6538cfdb12 Mon Sep 17 00:00:00 2001 From: Ana Laura Ferraz Date: Sat, 24 Oct 2026 21:38:25 -0300 Subject: [PATCH 01/18] docs: specify the fix of the analysis engine's artifacts --- .../checklists/requirements.md | 36 ++++ specs/005-analysis-engine-artifacts/spec.md | 189 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 specs/005-analysis-engine-artifacts/checklists/requirements.md create mode 100644 specs/005-analysis-engine-artifacts/spec.md 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..c793ed5 --- /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 + +- [ ] 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 + +- Three [NEEDS CLARIFICATION] markers remain (FR-009 corner bounds nullable, FR-012 `totalDeltaS` corrected or removed, FR-018 local cache rows with old insights); resolve with `/speckit-clarify` before `/speckit-plan`. +- 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/spec.md b/specs/005-analysis-engine-artifacts/spec.md new file mode 100644 index 0000000..b47daba --- /dev/null +++ b/specs/005-analysis-engine-artifacts/spec.md @@ -0,0 +1,189 @@ +# Feature Specification: Fix the analysis engine's artifacts + +**Feature Branch**: `005-analysis-engine-artifacts` + +**Created**: 2026-10-24 + +**Status**: Draft + +**Input**: User description (verbatim, as given to `/speckit-specify`): + +> Spec da API: corrigir os artefatos do motor de análise. +> +> Contexto: na spec 003 (modo demo, branch 003-demo-mode, PR #12 em rascunho), a leitura dos textos do coach levou a uma investigação que encontrou números suspeitos nos insights do motor de análise. A spec 004 (já na main) corrigiu como o coach lê os insights, mas não os insights em si: o coach agora explica com clareza o que o motor calcula, inclusive o que pode ser artefato. As observações estão no research.md da branch 003: leia com git show 003-demo-mode:specs/003-demo-mode/research.md, seção "Analysis engine observations". As seis mensagens da demo, com os insights atuais, estão em specs/004-coach-input/research.md, seção 4, na main. +> +> As observações, ainda hipóteses coerentes com o código, não medidas: +> - o totalDeltaS não é simétrico e não bate com a diferença oficial: (6,7) dá +0.28 e (7,6) dá -0.20, contra 0.162 s oficiais; (7,8) dá +0.72 e (8,7) dá -0.65, contra 0.772 s; +> - curvas vizinhas com ápices próximos (por exemplo a 334 m e a 399 m) caem em janelas de busca de ±80 m que se sobrepõem e pegam o mesmo ponto, produzindo freada, pico e retomada de acelerador idênticos; +> - o início da freada para no ápice quando o freio soltou antes dele, gerando diferenças de 100 a 190 m e combinações como "freou 179.97 m depois" com "freou mais leve (1)"; +> - a mesma retomada de acelerador é atribuída a curvas vizinhas; +> - a volta 6 como principal detecta 12 curvas, e as outras, 7. +> +> O que a spec precisa cobrir (o quê e por quê; o como fica para o plano): +> - Investigação primeiro: antes de qualquer correção, os dados crus da gravação publicada nas curvas suspeitas mostram, para cada observação, se ela é artefato do motor ou se aconteceu de fato na pista. A spec diz que nenhuma correção é feita sobre uma hipótese não confirmada. +> - Os insights de uma curva descrevem aquela curva, e não um ponto compartilhado com a vizinha. +> - O início da freada e a retomada do acelerador refletem o que as amostras mostram, ou o campo fica nulo quando não puder ser determinado, em vez de um valor inventado. +> - Propriedades que um motor correto precisa ter, como critérios de sucesso: a simetria entre comparar A com B e B com A, dentro de uma tolerância; a coerência do delta total com a diferença oficial de tempo de volta, ou a decisão explícita de não expô-lo; e o número de curvas detectadas não depender de qual volta é a principal sem uma razão registrada. +> - O que muda nos insights muda a entrada do coach: registre que a spec 003 regenera o arquivo de cache depois desta spec, e que o PROMPT_VERSION não muda por causa dela (os insights entram na comparação com tolerância, não na versão). +> - O front tem uma cópia do algoritmo do delta acumulado (o gráfico "Delta acumulado"). Uma mudança no algoritmo do backend deixa a cópia do front diferente: registre isso como dependência de uma spec do front, sem mudar o front aqui. +> +> Fora do escopo: o front, o modo demo e o arquivo de cache da demo (spec 003), o prompt e a mensagem do coach (spec 004, já feita), o flashback e a out-lap (a spec do flashback), exceto o que a investigação mostrar que é necessário para entender a diferença no número de curvas da volta 6, registrado como achado. +> +> Regras: nenhuma chamada paga. O .env do repositório tem a chave: qualquer comando que carregue o dotenv roda com DOTENV_CONFIG_PATH apontando para um .env sem chave fora do repositório, depois da conferência true/false, que tem de imprimir false. Nada aponta para o data/telemetry.db, que ainda não passou pela migração da spec 004: toda investigação usa um banco temporário montado a partir da gravação publicada. Commits com os tipos da lista fechada da constituição, sem trailer Co-Authored-By; PR sem rodapé "Generated with". Edição só com Edit e Write, nunca com shell. A documentação se refere à mantenedora pelo papel. +> +> Quando terminar, commite a spec como docs:, sem trailer, e me mostre o resumo: os requisitos, os critérios de sucesso e as perguntas abertas. + +## Background (read in the code, `main` at `7dc6685`) + +Facts this spec builds on. The observations and their numbers are in `003-demo-mode:specs/003-demo-mode/research.md`, section "Analysis engine observations" (computed by `buildInsights` on the published recording, code references at `ef19bf4`); the six demo messages with today's insights are in `specs/004-coach-input/research.md`, section 4. None of the causes below has been measured; they are readings of the code consistent with the numbers. + +- **The published recording** is `demo-interlagos-tt-20hz-laps6-8.jsonl` (release `sample-recording-v1`, README): a Time Trial at Interlagos at a 20 Hz send rate, laps 6 (74.306 s, invalid), 7 (74.144 s) and 8 (73.372 s). A 1x replay into a temporary database stores 2,411 car telemetry and 2,411 motion samples (797 / 801 / 793 for laps 6–8, 20 for lap 9). The stored resolution is about one sample every 4 m (decimation at 4 m or 500 ms, README); no point along the lap can be located more precisely than that. +- **`totalDeltaS`** is the last value of the cumulative delta series (`src/analysis/buildInsights.ts`), which normalizes each lap by its own first stored sample's `sessionTime` and clamps the comparison lap's time at its own first and last samples (`src/analysis/segmentDelta.ts`). The three pairs measured: (6,7) +0.28 / (7,6) −0.20 against an official ±0.162; (7,8) +0.72 / (8,7) −0.65 against ±0.772; (6,8) +0.93 / (8,6) −0.92 against ±0.934 — so one pair out of three is already close to symmetric and to the official difference. +- **Corners are detected on the primary lap only** (`detectCorners`, `src/analysis/cornerDetection.ts`): with lap 6 as primary, 12 corners; with lap 7 or 8, 7. So (A, B) and (B, A) can describe different corners. +- **Braking and throttle per corner** look for the "equivalent" apex in each lap as the slowest sample within ±80 m of the primary lap's apex (`findApexIndexNear`, `src/analysis/sampleSearch.ts`; `CORNER_SEARCH_WINDOW_M` in `brakingAnalysis.ts` and `throttleAnalysis.ts`). Two corners whose apexes are less than 160 m apart share part of their windows. +- **The braking start** (`findBrakingStartIndex`) walks back from the apex only while the previous sample is braking above 0.1: when the brake is off at the apex, the braking start is the apex itself. `brakingStartDistance` equals `apexDistance` in 10 of 12 corners with lap 6 primary, 6 of 7 with lap 7, 4 of 7 with lap 8. +- **The throttle pick-up** (`findThrottleOnIndex`) walks forward from the apex to the first sample at full throttle (0.9), with no limit at the next corner. +- **The same two functions define the corner's own bounds** (`Corner.brakingStartDistance`, `Corner.exitDistance`), which in turn bound `deltaLossS` (`cornerLoss`) and the line deviation summary. So an artifact in the braking start or the throttle pick-up also changes `deltaLossS`, which decides the coach's opportunities and the frontend's chips. +- **The insights are part of the REST contract**: `POST /sessions/:id/analysis` answers `{ analysis, insights, cached }` (`src/http/routes/analysisRouter.ts`), and the frontend mirrors `LapInsights` by hand (`Telemetry-F1/src/types/rest.ts`: `brakingStartDistance: number`, `exitDistance: number`, `totalDeltaS: number`). The frontend uses `corner.brakingStartDistance` and `corner.exitDistance` to highlight the selected corner on its charts (`SessionAnalysisPage.tsx`), shows `apexDistance` and `deltaLossS` on the coach chips, and does not display `totalDeltaS`. +- **The frontend has its own copy of the delta algorithm** (`Telemetry-F1/src/components/chart/computeDelta.ts`, drawn as the "Delta acumulado" chart); the backend's `segmentDelta.ts` says it is an intentional port of it. +- **The prompt version** (`src/coaching/promptVersion.ts`) is a hash of the system prompt and of the coach messages built from three fixed, synthetic `REFERENCE_INPUTS`, not from real insights. Changing the engine's *values* does not change it. Changing the *shape* of `LapInsights` (a field removed or renamed) changes the reference inputs and therefore the version, and changing a field's *meaning* would need the prompt to describe it differently (it says today, line 14 of `coachPrompt.ts`, that `totalDeltaS` "não é a diferença oficial de tempo de volta; não o cite"), which also changes the version. +- **The local analysis cache** (`lap_analyses`, spec 004) marks a row stale only when its prompt version or model differs from the current ones; it returns the insights stored with the row, not freshly computed ones. + +## Clarifications + +### Session 2026-10-24 + +(none yet — see the three [NEEDS CLARIFICATION] markers below) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Each suspect number is classified before anything is changed (Priority: P1) + +The maintainer, before accepting any change to the engine, reads, for each of the five observations, what the recording's own samples show around the suspect corners: whether the driver really braked 180 m earlier, really never reached full throttle between two corners, really took a different number of corners, or whether the engine made it up. Only observations confirmed as engine artifacts are fixed. + +**Why this priority**: the observations are hypotheses read from the code. Fixing an engine behaviour that turns out to describe what happened on track would replace a true number with a false one; the constitution forbids inventing values (principle IV). + +**Independent Test**: from a temporary database built from the published recording, the investigation record lists each observation with its verdict and the samples (distance, speed, brake, throttle, time) that support it, and the maintainer can re-run the same reading and get the same samples. + +**Acceptance Scenarios**: + +1. **Given** observation 2 (corners at 334 m and 399 m with identical values in (6,7)), **When** the investigation reads both laps' samples between the two corners, **Then** it records which sample each corner's analysis used in each lap and whether the two corners are two corners on the track, one corner seen twice, or undeterminable. +2. **Given** observation 3 (braking start equal to the apex), **When** the investigation reads the brake trace before each such apex, **Then** it records whether the lap braked for that corner, where the braking began and ended, and whether the reported difference (for example −179.97 m) matches anything on the track. +3. **Given** observation 5 (12 corners with lap 6 primary, 7 otherwise), **When** the investigation compares lap 6's speed trace with laps 7 and 8, **Then** it records the extra corners' distances and the reason they are detected (driving, out-lap or flashback samples, noise), as a finding; a fix that belongs to the flashback spec is recorded there, not made here. +4. **Given** an observation the investigation finds to be real driving, **When** the plan is written, **Then** no requirement changes that behaviour, and the record says so. + +--- + +### User Story 2 - A corner's insights describe that corner (Priority: P1) + +Someone reading the analysis of a pair, on screen or through the coach, gets braking, peak braking and throttle values for each corner that come from that corner, and gets "unknown" instead of a number when the samples do not show it. + +**Why this priority**: shared points between neighbouring corners and braking starts that fall on the apex produce the 100–190 m differences the coach explains as facts; they also move the corner bounds and therefore `deltaLossS`, which picks the three opportunities. + +**Independent Test**: on synthetic laps built so that two corners are close, a lap releases the brake before the apex, a lap never brakes for a corner, and no full throttle occurs between two corners, the insights of each corner use points inside that corner only and give `null` where the samples do not determine the value. + +**Acceptance Scenarios**: + +1. **Given** two corners whose apexes are 65 m apart, each with its own braking and its own minimum speed, **When** the insights are built, **Then** each corner's braking start, apex and throttle pick-up in each lap are different samples, inside that corner's own stretch. +2. **Given** a lap that braked from 150 m before the apex to 30 m before it and then released, **When** the braking start is determined, **Then** it is the sample where that braking began, not the apex. +3. **Given** a corner where one of the two laps did not brake at all (a lift, or flat out), **When** the insights are built, **Then** the braking-point difference for that corner is `null`, not a difference between a braking point and an apex. +4. **Given** two corners with no full-throttle sample between them in a lap, **When** the insights are built, **Then** the first corner's throttle pick-up in that lap is not the second corner's: it is `null` for the first corner (or determined by a rule that stays inside the first corner), and the two corners never report the same pick-up. +5. **Given** a confirmed artifact that changed a corner's bounds, **When** the insights are rebuilt, **Then** `deltaLossS` and the line deviation of that corner are measured over the corrected bounds. + +--- + +### User Story 3 - Comparing A with B and B with A tells the same story (Priority: P2) + +The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite differences of the same size, and the total delta either agrees with the official lap-time difference or is not offered as a number. + +**Why this priority**: an analysis that changes its corners and its numbers when the two laps are swapped cannot be explained to a visitor; but it depends on Stories 1 and 2 (shared points and false braking starts are themselves sources of asymmetry). + +**Independent Test**: for the six ordered pairs of laps 6, 7 and 8 of the published recording, built in a temporary database, compare each pair with its reverse and with the official times. + +**Acceptance Scenarios**: + +1. **Given** the pairs (A, B) and (B, A), **When** both insights are built, **Then** they describe the same corners (same count and apexes within the stated tolerance), or the record gives the reason they differ. +2. **Given** a corner present in both directions with a non-null difference in both, **When** the two values are compared, **Then** one is the negative of the other within the stated tolerance. +3. **Given** a pair with official times known, **When** the total delta is reported, **Then** it agrees with the official difference within the stated tolerance, or it is not reported (decision recorded, see FR-012). + +--- + +### Edge Cases + +- A corner where both laps did not brake: braking-point and peak-brake differences are reported as the samples show (no braking point in either lap → braking-point difference `null`; peak braking 0 in both → peak difference 0), with no invented braking point. +- A corner where only one lap braked: braking-point difference `null`; the peak-brake difference is still a measured fact (one lap at some peak, the other at 0) and is kept. +- A lap that brakes twice before one apex (a dab, then the main braking): the investigation records which one the engine uses; the rule chosen is recorded in the plan. +- A corner near the start/finish line, where one lap's samples begin after the other's: values that would need samples outside the lap are `null`. +- A comparison lap missing samples in a corner (gap larger than the stated tolerance): the values that depend on them are `null`. +- The out-lap of a Time Trial sharing its `lap_number` with the first flying lap, and a flashback inside a lap (known limitation): out of scope; if the investigation shows they explain lap 6's extra corners, that is recorded as a finding and the corner-count criterion accepts it as the recorded reason. +- Two laps with equal official times: the total delta's expected value is zero. + +## Requirements *(mandatory)* + +### Investigation first + +- **FR-001**: Before any change to the analysis code, the investigation MUST read, for each of the five observations, the samples of the published recording around the suspect corners — both the samples stored after decimation (what the engine sees) and, where they differ, the recording's own packets — and record the samples it relies on (distance, speed, brake, throttle, time) in this feature's `research.md`. +- **FR-002**: Each observation MUST receive one recorded verdict: an artifact of the engine (with the code that produces it), something that happened on track, an effect of decimation or sample resolution, or undeterminable from the data. +- **FR-003**: No correction MUST be made for an observation not confirmed as an engine artifact by FR-002. An observation found to be real driving is left as it is and stays recorded; an undeterminable one is recorded as open, with no change. +- **FR-004**: The investigation MUST include the reason lap 6 as primary yields 12 corners and laps 7 and 8 yield 7, recorded as a finding even when the cause is the out-lap or a flashback; fixing those belongs to the flashback spec. +- **FR-005**: The investigation MUST run on a temporary database built from the published recording with no sample loss (the 2,411 car telemetry and 2,411 motion samples of a 1x replay), never on `data/telemetry.db`. + +### Each corner describes itself + +- **FR-006**: The points a corner's insights use in each lap (equivalent apex, braking start, throttle pick-up) MUST lie within that corner's own stretch of track; two corners of the same insights MUST NOT use the same sample of the same lap as their apex, their braking start or their throttle pick-up. +- **FR-007**: The braking start MUST be the sample where the braking that precedes that corner's apex began, within the corner's stretch; when the lap did not brake for that corner, the braking start is unknown and the braking-point difference is `null`. +- **FR-008**: The throttle pick-up MUST be the first full-throttle sample after the apex within the corner's stretch; when none exists before the next corner's stretch begins, it is unknown and the throttle difference is `null`. +- **FR-009**: The corner's own bounds (used by `deltaLossS`, the line deviation summary and the frontend's highlight) MUST follow the same rules, so a confirmed artifact is removed from them as well. [NEEDS CLARIFICATION: when a corner's braking start or throttle pick-up is unknown in the primary lap, do the corner's bounds (`brakingStartDistance`, `exitDistance`, `number` today in the contract) become nullable — a contract change, with `deltaLossS` and the frontend highlight then needing a rule for a missing bound — or do the bounds stay numbers, defined by the corner's stretch, while only the difference fields become `null`?] +- **FR-010**: Unknown values MUST be `null`, never estimated (constitution, principle IV); fields that already allow `null` keep that meaning. + +### Properties of a correct engine + +- **FR-011**: Comparing A with B and B with A MUST describe the same corners, or the reason they differ MUST be recorded in `research.md`; for a corner present in both, every non-null difference of (A, B) MUST be the negative of (B, A) within the stated tolerance. +- **FR-012**: The total delta MUST either agree with the official lap-time difference (primary − comparison, from the stored lap times) within the stated tolerance, or stop being exposed, by a decision recorded in the plan. [NEEDS CLARIFICATION: is `totalDeltaS` corrected (kept in `LapInsights`, same name, meaning becomes "agrees with the official difference") or removed from the insights (a contract change and a change of the prompt-version reference inputs, so the prompt version changes — see FR-017)? A third option is to decide after the investigation, in the plan.] +- **FR-013**: The number of corners detected MUST NOT depend on which lap is primary without a reason recorded in `research.md`. +- **FR-014**: The tolerances of FR-011 and FR-012 MUST be fixed in the plan from the data's resolution (about 4 m per sample, the time between stored samples), before any correction is implemented, and MUST NOT be widened afterwards to make a result pass. +- **FR-015**: `src/analysis/` stays pure and deterministic. Each property (no shared points between corners, braking start not at the apex when the brake was released before it, `null` when undeterminable, symmetry) MUST be covered by tests on synthetic fixtures (`src/analysis/testFixtures.ts`), with no database and no paid call. + +### What depends on the insights + +- **FR-016**: Any change to the shape of `LapInsights` (a field removed, renamed or made nullable) MUST be called out in the plan as a contract change for the frontend's `src/types/rest.ts` (constitution, principle VI). A change of values only is not a shape change, but the plan MUST still list the values the visitor sees that change (`deltaLossS` on the chips, the highlighted corner's bounds). +- **FR-017**: A change of the insights' values changes the coach's input but MUST NOT change `PROMPT_VERSION`: the version hashes the prompt and fixed synthetic reference inputs, and the demo's seed file compares insights with a tolerance (spec 003), not through the version. A test MUST show the version is unchanged by this feature's value changes. If a decision of FR-009 or FR-012 changes the shape or the documented meaning of a field the prompt describes, the version changes automatically; that consequence MUST be stated in the plan and is accepted only with that decision. +- **FR-018**: The local analysis cache (`lap_analyses`) returns the insights stored with each row, and a row is stale only for another prompt version or model (spec 004), so a row cached before this feature keeps serving old insights and its text. [NEEDS CLARIFICATION: does this feature make such rows stale (for example by comparing stored and recomputed insights, as the demo seed does), leave them as they are and document it as a limitation, or leave the clean-up to the maintainer?] +- **FR-019**: After this feature merges, spec 003 regenerates the demo's seed file from the corrected insights; this spec does not touch the seed file, demo mode or the frontend. `research.md` MUST record, for the six ordered pairs of laps 6, 7 and 8, the insights before and after, as input to that regeneration and to the maintainer's reading of the new texts. +- **FR-020**: If the backend's delta algorithm changes, the frontend's copy (`computeDelta`, "Delta acumulado" chart) no longer matches it. This MUST be recorded as a dependency for a frontend spec, with the change described precisely enough to port; the frontend is not changed here. + +### Rules of work + +- **FR-021**: No step makes a paid call. Every command that loads the dotenv configuration runs with `DOTENV_CONFIG_PATH` pointing at a `.env` without the key, outside the repository, after a presence check that prints `false`. Nothing points at `data/telemetry.db`, which has not yet been migrated by spec 004. +- **FR-022**: Documentation (README, `CLAUDE.md`) MUST describe the engine as it is after the change; a limitation fixed here is removed from both files in the same piece of work, and one found but not fixed is added to both. + +### Key Entities + +- **Observation**: one of the five suspect behaviours, with its verdict (engine artifact, real driving, decimation effect, undeterminable) and the samples that support it. +- **Corner stretch**: the part of the track that belongs to one corner, bounded so that it does not overlap its neighbours; every point a corner's insights use lies in it. +- **Corner insight**: braking, throttle, line deviation and `deltaLossS` of one corner, each value either measured from that corner's samples or `null`. +- **Total delta**: the time difference at the end of the lap; either coherent with the official difference or not exposed. +- **Tolerance**: the stated, fixed margin for symmetry and for agreement with the official difference, derived from the sample resolution. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Before the first implementation commit, `research.md` gives each of the five observations a verdict and the samples supporting it, and every correction in the plan traces back to an observation with the verdict "engine artifact"; none traces to an unconfirmed one. +- **SC-002**: For each of the six ordered pairs of laps 6, 7 and 8, no two corners of the same insights use the same sample of the same lap as apex, braking start or throttle pick-up. +- **SC-003**: For each of the six pairs, no reported braking start equals its apex in a lap where the samples show the brake was applied and released before the apex; where a lap did not brake for a corner, its braking-point difference is `null`. In particular, the differences of 100–190 m listed in observation 3 either disappear or are confirmed by the samples as real. +- **SC-004**: For each of the three pairs of laps and both directions, every corner present in both directions has differences that are opposite within the stated tolerance, and the total delta of (A, B) plus that of (B, A) is zero within the stated tolerance. +- **SC-005**: For each of the six pairs, the total delta agrees with the official difference (±0.162, ±0.772, ±0.934 s) within the stated tolerance, or it is not in the insights and the decision is recorded. +- **SC-006**: The six pairs give the same corner count for (A, B) and (B, A), or the difference is explained in `research.md` (for example by the lap 6 finding). +- **SC-007**: The properties of FR-015 are covered by tests that pass with no API key and no database; `PROMPT_VERSION` is the same before and after the feature, unless a recorded decision of FR-009/FR-012 changes it. +- **SC-008**: No paid call and no access to `data/telemetry.db` at any step; every dotenv-loading command was preceded by a key check printing `false`. + +## Assumptions + +- The published recording and its three laps are the evidence base; conclusions are not generalized to other tracks without saying so. +- The official lap time (stored from the game's `lastLapTimeInMS`) is authoritative (constitution, principle IV) and is the reference for the total delta. +- The sample resolution (about 4 m) bounds how precisely any point can be located; differences smaller than it are not treated as meaningful. +- The decimation rule (4 m or 500 ms) is not changed by this spec; if the investigation finds that it causes an observation, that is recorded as a finding. +- The coach's prompt and message building (spec 004) are not changed by this spec, except as an automatic consequence recorded under FR-017. +- The frontend's `topOpportunities` rule and chip label are not changed; they keep consuming `deltaLossS` and `apexDistance`. +- The out-lap and flashback (known limitations) are handled by the flashback spec. From efe2f2c28b0d0954e8b15dd5c946312b7c1eeb0d Mon Sep 17 00:00:00 2001 From: Ana Laura Ferraz Date: Sat, 24 Oct 2026 22:29:52 -0300 Subject: [PATCH 02/18] docs: record the clarifications of spec 005 --- .../checklists/requirements.md | 4 +- specs/005-analysis-engine-artifacts/spec.md | 54 +++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/specs/005-analysis-engine-artifacts/checklists/requirements.md b/specs/005-analysis-engine-artifacts/checklists/requirements.md index c793ed5..004b5b1 100644 --- a/specs/005-analysis-engine-artifacts/checklists/requirements.md +++ b/specs/005-analysis-engine-artifacts/checklists/requirements.md @@ -13,7 +13,7 @@ ## Requirement Completeness -- [ ] No [NEEDS CLARIFICATION] markers remain +- [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) @@ -31,6 +31,6 @@ ## Notes -- Three [NEEDS CLARIFICATION] markers remain (FR-009 corner bounds nullable, FR-012 `totalDeltaS` corrected or removed, FR-018 local cache rows with old insights); resolve with `/speckit-clarify` before `/speckit-plan`. +- 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/spec.md b/specs/005-analysis-engine-artifacts/spec.md index b47daba..8e2f35e 100644 --- a/specs/005-analysis-engine-artifacts/spec.md +++ b/specs/005-analysis-engine-artifacts/spec.md @@ -53,7 +53,13 @@ Facts this spec builds on. The observations and their numbers are in `003-demo-m ### Session 2026-10-24 -(none yet — see the three [NEEDS CLARIFICATION] markers below) +- Q: When a corner's braking start or throttle pick-up cannot be determined in the primary lap, do the corner's bounds (`brakingStartDistance`, `exitDistance`) become nullable, or do they stay numbers? → A: They stay numbers. The two roles `brakingStartDistance` holds today are separated: the driver's behaviour (where the lap began to brake, where it was back on full throttle) and the corner's bounds (the stretch the frontend highlights and over which `deltaLossS` is measured). The corner's stretch is defined by the corner itself and always has a numeric value; only the behaviour differences become `null` when they cannot be determined. If the plan concludes that a field's name became misleading, renaming it is a plan decision, recorded as a contract change and as a dependency of the frontend spec. +- Q: Is `totalDeltaS` corrected or removed from the insights? → A: Decided in the plan, after the investigation, with this recorded preference: correct it if the cause is confirmed and a definition exists that agrees with the official difference within the tolerance; remove it only if none does. Either way, the impact on the prompt and on `PROMPT_VERSION` is stated in the plan. Added with this answer: the investigation also checks each corner's `deltaLossS`, which comes from the same delta series and is what picks and orders the opportunities the coach explains; the symmetry property holds corner by corner, not only for the total. +- Q: Does this feature make the `lap_analyses` rows cached before it stale? → A: One rule for both caches (the local `lap_analyses` and the demo's seed file). A cached analysis is valid while its prompt version, its model and its insights are the current ones; the stored insights are compared with freshly computed ones by the same tolerance rule as the demo's seed file. A row whose insights differ is stale: with the key, it is generated again; without the key, the request answers 503. This changes spec 004's staleness rule and holds for any future change to the engine, with no manual clean-up. +- Q: The tolerance rule FR-018 reuses (`insightsMatch`: structural comparison, numbers within 0.01 + 1e-9) exists only on the unmerged `003-demo-mode` branch (`src/demo/insightsMatch.ts`); where does it come from on `main`? → A: Option A. This feature brings the function and its tests to `main` in a neutral module (for example under `src/coaching/`, since the local cache is not demo code), with the same rule; the commit message says where it came from (`src/demo/insightsMatch.ts` on `003-demo-mode`). Spec 003 is not rebased (its branch is published): when spec 003 is updated with `main`, it imports this module and deletes its own copy; the path of that update is recorded in spec 003's `tasks.md`. +- Q: Are the properties measured on the six pairs of the published recording (SC-002–SC-006) checked by an automated test or by a manual check recorded in `research.md`? → A: Both, options A and C together. (1) Synthetic tests for each case the investigation finds. (2) A cut of the published recording, versioned as a test fixture: the decimated samples of the three whole laps (not only the suspect corners, because the total delta against the official difference needs the whole lap), only the fields the engine uses, generated by a reproducible script that records the SHA-256 of the source recording; the plan measures its size and sets a ceiling. The tests on the cut check properties (no point shared between corners, symmetry corner by corner and in total, total delta against the official difference within the tolerance, corner count), not exact values. (3) The exception to the rule "recordings stay out of git" is recorded where that rule is written, with the reason: the cut has none of the packets the engine does not use, is small, and comes from data already public as a Release asset; if the rule is in the constitution, the plan proposes the amendment, which would also remove the Sync Impact Report from the top of that file and evaluate adding `refactor:` to the commit types. (4) The six-pair check with the documented command stays, its output recorded in `research.md` before and after the correction. (Read at the clarify: the rule is written in `CLAUDE.md`, Rules — "`.env`, `data/` and `recordings/` are gitignored" — in `.gitignore` and in the README's "published as a release asset (not committed to git)", not in the constitution, so no amendment is triggered; see FR-015c.) +- Q: If the investigation shows that lap 6's extra corners come from the out-lap or a flashback, is the recorded reason enough for the corner-count criterion, or must this spec make (6, x) and (x, 6) describe the same corners? → A: Option C. The recorded reason is enough, symmetry is required on the corners present in both directions, and the fix belongs to the flashback spec; in addition, the test on the recording cut checks that the extra corners exist only in the pairs where lap 6 is the primary, with a comment saying it describes a known limitation and that the flashback spec inverts it when it fixes the cause. This holds only if the investigation confirms the out-lap or a flashback as the cause; if the extra corners come from anything else, they are this spec's subject again. Demo impact, recorded: the frontend always uses the most recent lap as the primary (`Telemetry-F1/src/pages/sessionAnalysis/SessionAnalysisPage.tsx:62`, `laps[laps.length - 1]`, read on the frontend's `main` at `9334f87`), so no visitor sees lap 6 as the primary; the texts of (6, 7) and (6, 8) go into the seed file but are not shown on screen. +- Q: If this spec changes the cumulative delta algorithm, must the frontend spec that ports the change be live before the demo is published, or is a temporary divergence acceptable? → A: Option A, with option D's condition built in. Blocking only for publishing the demo: this spec merges on its own, but spec 003 does not publish the demo until the frontend spec is live with the new algorithm; the dependency is recorded in both specs. It applies only if the plan changes the cumulative delta algorithm; if it does not, the dependency does not apply and that is recorded. Also recorded, as an alternative for the frontend spec to evaluate: instead of porting the new algorithm to the frontend's copy, the API delivers the delta series ready-made and the frontend stops computing it, removing the duplication — a contract change, to be decided in that spec, together with the same way out already recorded for the opportunity rule (`specs/004-coach-input/research.md`, section 2, "Known debt"). ## User Scenarios & Testing *(mandatory)* @@ -103,7 +109,7 @@ The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite di **Acceptance Scenarios**: 1. **Given** the pairs (A, B) and (B, A), **When** both insights are built, **Then** they describe the same corners (same count and apexes within the stated tolerance), or the record gives the reason they differ. -2. **Given** a corner present in both directions with a non-null difference in both, **When** the two values are compared, **Then** one is the negative of the other within the stated tolerance. +2. **Given** a corner present in both directions with a non-null difference in both (`deltaLossS` included), **When** the two values are compared, **Then** one is the negative of the other within the stated tolerance. 3. **Given** a pair with official times known, **When** the total delta is reported, **Then** it agrees with the official difference within the stated tolerance, or it is not reported (decision recorded, see FR-012). --- @@ -115,8 +121,10 @@ The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite di - A lap that brakes twice before one apex (a dab, then the main braking): the investigation records which one the engine uses; the rule chosen is recorded in the plan. - A corner near the start/finish line, where one lap's samples begin after the other's: values that would need samples outside the lap are `null`. - A comparison lap missing samples in a corner (gap larger than the stated tolerance): the values that depend on them are `null`. -- The out-lap of a Time Trial sharing its `lap_number` with the first flying lap, and a flashback inside a lap (known limitation): out of scope; if the investigation shows they explain lap 6's extra corners, that is recorded as a finding and the corner-count criterion accepts it as the recorded reason. +- The out-lap of a Time Trial sharing its `lap_number` with the first flying lap, and a flashback inside a lap (known limitation): out of scope; if the investigation confirms they explain lap 6's extra corners, that is the recorded reason of FR-013, checked by a test that the flashback spec inverts; any other cause keeps the extra corners in this spec's scope. - Two laps with equal official times: the total delta's expected value is zero. +- A cached analysis whose laps no longer have samples: its insights cannot be recomputed, so it cannot be confirmed as current; it is not served, and the request answers as today for a pair without samples. +- A pair answered from the local cache before this feature, without the key: after it, the row is stale and the request answers 503, a status and body the frontend already handles for uncached pairs. ## Requirements *(mandatory)* @@ -131,26 +139,32 @@ The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite di ### Each corner describes itself - **FR-006**: The points a corner's insights use in each lap (equivalent apex, braking start, throttle pick-up) MUST lie within that corner's own stretch of track; two corners of the same insights MUST NOT use the same sample of the same lap as their apex, their braking start or their throttle pick-up. -- **FR-007**: The braking start MUST be the sample where the braking that precedes that corner's apex began, within the corner's stretch; when the lap did not brake for that corner, the braking start is unknown and the braking-point difference is `null`. -- **FR-008**: The throttle pick-up MUST be the first full-throttle sample after the apex within the corner's stretch; when none exists before the next corner's stretch begins, it is unknown and the throttle difference is `null`. -- **FR-009**: The corner's own bounds (used by `deltaLossS`, the line deviation summary and the frontend's highlight) MUST follow the same rules, so a confirmed artifact is removed from them as well. [NEEDS CLARIFICATION: when a corner's braking start or throttle pick-up is unknown in the primary lap, do the corner's bounds (`brakingStartDistance`, `exitDistance`, `number` today in the contract) become nullable — a contract change, with `deltaLossS` and the frontend highlight then needing a rule for a missing bound — or do the bounds stay numbers, defined by the corner's stretch, while only the difference fields become `null`?] +- **FR-007**: A lap's braking start for a corner (driver behaviour) MUST be the sample where the braking that precedes that corner's apex began, within the corner's stretch; when the lap did not brake for that corner, its braking start is unknown and the braking-point difference is `null`. +- **FR-008**: A lap's throttle pick-up for a corner (driver behaviour) MUST be the first full-throttle sample after the apex within the corner's stretch; when none exists before the next corner's stretch begins, it is unknown and the throttle difference is `null`. +- **FR-009**: The two roles the corner's `brakingStartDistance` holds today MUST be separated: the driver's behaviour (FR-007, FR-008) and the corner's bounds — the stretch the frontend highlights and over which `deltaLossS` and the line deviation summary are measured. The corner's stretch MUST be defined by the corner itself, not by where a lap braked or accelerated, and MUST always have numeric bounds (`brakingStartDistance` and `exitDistance` stay `number` in the contract); only the behaviour differences become `null` when undeterminable. If the plan concludes that a field's name became misleading (for example `brakingStartDistance` for a bound that is no longer a braking point), renaming it is a plan decision, recorded as a contract change and as a dependency of the frontend spec (FR-016). - **FR-010**: Unknown values MUST be `null`, never estimated (constitution, principle IV); fields that already allow `null` keep that meaning. ### Properties of a correct engine -- **FR-011**: Comparing A with B and B with A MUST describe the same corners, or the reason they differ MUST be recorded in `research.md`; for a corner present in both, every non-null difference of (A, B) MUST be the negative of (B, A) within the stated tolerance. -- **FR-012**: The total delta MUST either agree with the official lap-time difference (primary − comparison, from the stored lap times) within the stated tolerance, or stop being exposed, by a decision recorded in the plan. [NEEDS CLARIFICATION: is `totalDeltaS` corrected (kept in `LapInsights`, same name, meaning becomes "agrees with the official difference") or removed from the insights (a contract change and a change of the prompt-version reference inputs, so the prompt version changes — see FR-017)? A third option is to decide after the investigation, in the plan.] -- **FR-013**: The number of corners detected MUST NOT depend on which lap is primary without a reason recorded in `research.md`. +- **FR-011**: Comparing A with B and B with A MUST describe the same corners, or the reason they differ MUST be recorded in `research.md`; for a corner present in both, every non-null difference of (A, B) — `deltaLossS` included — MUST be the negative of (B, A) within the stated tolerance. Symmetry holds corner by corner, not only for the total. +- **FR-011a**: The investigation (FR-001) MUST also check each corner's `deltaLossS` in the six ordered pairs: it comes from the same delta series as `totalDeltaS`, and it picks and orders the opportunities the coach explains and the visitor sees as chips. Its findings follow FR-002 and FR-003 like the five observations. +- **FR-012**: The total delta MUST either agree with the official lap-time difference (primary − comparison, from the stored lap times) within the stated tolerance, or stop being exposed. The decision is taken in the plan, after the investigation, with this recorded preference: correct `totalDeltaS` if its cause is confirmed and a definition exists that agrees with the official difference within the tolerance; remove it only if none does. Either way the plan MUST state the impact on the prompt (which today tells the coach not to cite it) and on `PROMPT_VERSION` (FR-017). +- **FR-013**: The number of corners detected MUST NOT depend on which lap is primary without a reason recorded in `research.md`. If the investigation confirms that lap 6's extra corners come from the Time Trial out-lap or a flashback (two passes over the same distance in one lap, a known limitation), that confirmed cause is the recorded reason: symmetry (FR-011) is required on the corners present in both directions, the extra corners are listed in `research.md` with their distances and cause, and the fix belongs to the flashback spec. A test on the recording cut (FR-015b) MUST then check that the extra corners exist only in the pairs where lap 6 is the primary, with a comment saying it describes a known limitation and that the flashback spec inverts it when it fixes the cause. If the extra corners come from any other cause, they are this spec's subject and FR-013 applies in full. +- **FR-013a**: The demo impact MUST be recorded in `research.md`: the frontend always uses the most recent lap as the primary (`SessionAnalysisPage.tsx:62`), so no visitor sees lap 6 as the primary; the texts of (6, 7) and (6, 8) go into the seed file but are not shown on screen. - **FR-014**: The tolerances of FR-011 and FR-012 MUST be fixed in the plan from the data's resolution (about 4 m per sample, the time between stored samples), before any correction is implemented, and MUST NOT be widened afterwards to make a result pass. -- **FR-015**: `src/analysis/` stays pure and deterministic. Each property (no shared points between corners, braking start not at the apex when the brake was released before it, `null` when undeterminable, symmetry) MUST be covered by tests on synthetic fixtures (`src/analysis/testFixtures.ts`), with no database and no paid call. +- **FR-015**: `src/analysis/` stays pure and deterministic. Each property (no shared points between corners, braking start not at the apex when the brake was released before it, `null` when undeterminable, symmetry) MUST be covered by tests on synthetic fixtures (`src/analysis/testFixtures.ts`), each one designed from a case the investigation found, with no database and no paid call. +- **FR-015a**: A cut of the published recording MUST be versioned as a test fixture: the decimated samples of laps 6, 7 and 8, each lap whole, with only the fields the engine uses, plus the official lap times and validity needed for the checks. It MUST be generated by a reproducible script, committed with it, that records the SHA-256 of the source recording in the fixture. The plan measures the fixture's size and sets a ceiling, and a test fails if the fixture exceeds it. +- **FR-015b**: Tests on that fixture MUST check properties, not exact values: no point shared between corners, symmetry corner by corner and in total, total delta against the official difference within the tolerance (or its absence, per FR-012), and the corner count of FR-013. They run in the CI with no network, no database and no key. +- **FR-015c**: The fixture is an exception to the rule that recordings stay out of git, and it MUST be recorded where that rule is written — `CLAUDE.md`, Rules, and the README, where the recording is described as a release asset not committed to git — with the reason: the cut holds none of the packets the engine does not use, it is small, and it comes from data already public as a Release asset. The rule is not in the constitution, so no constitution amendment is part of this feature. The six-pair check with the documented command (FR-019) stays alongside these tests. ### What depends on the insights - **FR-016**: Any change to the shape of `LapInsights` (a field removed, renamed or made nullable) MUST be called out in the plan as a contract change for the frontend's `src/types/rest.ts` (constitution, principle VI). A change of values only is not a shape change, but the plan MUST still list the values the visitor sees that change (`deltaLossS` on the chips, the highlighted corner's bounds). - **FR-017**: A change of the insights' values changes the coach's input but MUST NOT change `PROMPT_VERSION`: the version hashes the prompt and fixed synthetic reference inputs, and the demo's seed file compares insights with a tolerance (spec 003), not through the version. A test MUST show the version is unchanged by this feature's value changes. If a decision of FR-009 or FR-012 changes the shape or the documented meaning of a field the prompt describes, the version changes automatically; that consequence MUST be stated in the plan and is accepted only with that decision. -- **FR-018**: The local analysis cache (`lap_analyses`) returns the insights stored with each row, and a row is stale only for another prompt version or model (spec 004), so a row cached before this feature keeps serving old insights and its text. [NEEDS CLARIFICATION: does this feature make such rows stale (for example by comparing stored and recomputed insights, as the demo seed does), leave them as they are and document it as a limitation, or leave the clean-up to the maintainer?] -- **FR-019**: After this feature merges, spec 003 regenerates the demo's seed file from the corrected insights; this spec does not touch the seed file, demo mode or the frontend. `research.md` MUST record, for the six ordered pairs of laps 6, 7 and 8, the insights before and after, as input to that regeneration and to the maintainer's reading of the new texts. -- **FR-020**: If the backend's delta algorithm changes, the frontend's copy (`computeDelta`, "Delta acumulado" chart) no longer matches it. This MUST be recorded as a dependency for a frontend spec, with the change described precisely enough to port; the frontend is not changed here. +- **FR-018**: One staleness rule MUST hold for both caches, the local `lap_analyses` and the demo's seed file: a cached analysis is valid while its prompt version, its model and its insights are the current ones. The stored insights MUST be compared with insights freshly computed for the pair by the same tolerance rule as the demo's seed file (spec 003). That rule (`insightsMatch`) MUST come to `main` in this feature, with its tests, in a module outside `src/demo/`, unchanged from `003-demo-mode:src/demo/insightsMatch.ts`, the origin named in the commit message; when spec 003 is updated with `main`, it imports this module instead of keeping its own copy (one rule, never two). A row whose insights differ is stale: with the API key configured, the text is generated again and the row replaced; without it, the request answers 503 and the old text is not served. This replaces spec 004's staleness rule (FR-014 there), needs no manual clean-up, and holds for any future change to the engine, not only this one. +- **FR-019**: After this feature merges, spec 003 regenerates the demo's seed file from the corrected insights; this spec does not touch the seed file, demo mode or the frontend. `research.md` MUST record, for the six ordered pairs of laps 6, 7 and 8, the insights before and after the correction, produced by a documented, reproducible command on a temporary database built from the published recording, as input to that regeneration and to the maintainer's reading of the new texts. +- **FR-020**: If the plan changes the backend's cumulative delta algorithm, the frontend's copy (`computeDelta`, "Delta acumulado" chart) no longer matches it, and the visitor would see the chart and the chips' `deltaLossS` disagree on the same screen. Then: this spec merges on its own, but spec 003 MUST NOT publish the demo until a frontend spec is live with the new algorithm; the dependency MUST be recorded in this spec's plan and in spec 003, with the change described precisely enough to port. If the plan does not change the algorithm, the dependency does not apply, and the plan says so. The frontend is not changed here. +- **FR-020a**: The frontend spec MUST be offered, as an alternative to porting, that the API deliver the cumulative delta series ready-made in the analysis response and the frontend stop computing it, removing the duplication. It is a contract change, decided in that spec together with the same way out already recorded for the opportunity rule (`specs/004-coach-input/research.md`, section 2). ### Rules of work @@ -160,8 +174,10 @@ The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite di ### Key Entities - **Observation**: one of the five suspect behaviours, with its verdict (engine artifact, real driving, decimation effect, undeterminable) and the samples that support it. -- **Corner stretch**: the part of the track that belongs to one corner, bounded so that it does not overlap its neighbours; every point a corner's insights use lies in it. -- **Corner insight**: braking, throttle, line deviation and `deltaLossS` of one corner, each value either measured from that corner's samples or `null`. +- **Corner stretch**: the part of the track that belongs to one corner, defined by the corner itself and bounded so that it does not overlap its neighbours; its bounds are always numbers (today's `brakingStartDistance` and `exitDistance`); every point a corner's insights use lies in it, and `deltaLossS` and the line deviation are measured over it. +- **Driver behaviour in a corner**: where each lap began to brake and was back on full throttle; each is a sample of that lap inside the corner's stretch, or unknown. +- **Corner insight**: braking, throttle, line deviation and `deltaLossS` of one corner; behaviour differences are measured from that corner's samples or `null`. +- **Cached analysis**: a stored text with its insights, prompt version and model, valid only while all three match the current ones (insights within the tolerance rule), in the local cache and in the demo's seed file alike. - **Total delta**: the time difference at the end of the lap; either coherent with the official difference or not exposed. - **Tolerance**: the stated, fixed margin for symmetry and for agreement with the official difference, derived from the sample resolution. @@ -172,10 +188,14 @@ The analysis of (7, 6) and of (6, 7) describe the same corners, with opposite di - **SC-001**: Before the first implementation commit, `research.md` gives each of the five observations a verdict and the samples supporting it, and every correction in the plan traces back to an observation with the verdict "engine artifact"; none traces to an unconfirmed one. - **SC-002**: For each of the six ordered pairs of laps 6, 7 and 8, no two corners of the same insights use the same sample of the same lap as apex, braking start or throttle pick-up. - **SC-003**: For each of the six pairs, no reported braking start equals its apex in a lap where the samples show the brake was applied and released before the apex; where a lap did not brake for a corner, its braking-point difference is `null`. In particular, the differences of 100–190 m listed in observation 3 either disappear or are confirmed by the samples as real. -- **SC-004**: For each of the three pairs of laps and both directions, every corner present in both directions has differences that are opposite within the stated tolerance, and the total delta of (A, B) plus that of (B, A) is zero within the stated tolerance. +- **SC-004**: For each of the three pairs of laps and both directions, every corner present in both directions has differences — `deltaLossS` included — that are opposite within the stated tolerance, and the total delta of (A, B) plus that of (B, A) is zero within the stated tolerance. +- **SC-004a**: For every corner of the six pairs, the corner's stretch has numeric bounds, and no braking-point or throttle difference is a difference between a behaviour point and a bound. +- **SC-004b**: In the tests, a cached analysis (local cache) whose stored insights differ from the freshly computed ones beyond the tolerance is never served: it is regenerated with a (fake) coach available and answers 503 without the key; one within the tolerance is served. - **SC-005**: For each of the six pairs, the total delta agrees with the official difference (±0.162, ±0.772, ±0.934 s) within the stated tolerance, or it is not in the insights and the decision is recorded. -- **SC-006**: The six pairs give the same corner count for (A, B) and (B, A), or the difference is explained in `research.md` (for example by the lap 6 finding). +- **SC-006**: The six pairs give the same corner count for (A, B) and (B, A), or the difference is explained in `research.md` by a confirmed out-lap or flashback cause, in which case a test on the recording cut shows the extra corners only in the pairs where lap 6 is the primary. +- **SC-006a**: The properties of SC-002, SC-004, SC-005 and SC-006 pass as automated tests on the versioned cut of the recording (FR-015a/b), in the CI, and the cut stays under the ceiling set in the plan; regenerating it with the script from a recording with the recorded SHA-256 gives the same file. - **SC-007**: The properties of FR-015 are covered by tests that pass with no API key and no database; `PROMPT_VERSION` is the same before and after the feature, unless a recorded decision of FR-009/FR-012 changes it. +- **SC-007a**: The plan states whether the cumulative delta algorithm changes; if it does, the publishing dependency (FR-020) is written in this plan and in spec 003 before this spec merges. - **SC-008**: No paid call and no access to `data/telemetry.db` at any step; every dotenv-loading command was preceded by a key check printing `false`. ## Assumptions From a9b0e2f961ec748e11b1a218f2b65d1b9cefe4b1 Mon Sep 17 00:00:00 2001 From: Ana Laura Ferraz Date: Sun, 25 Oct 2026 09:08:41 -0300 Subject: [PATCH 03/18] docs: plan the fix of the analysis engine's artifacts Investigation verdicts, design research, plan, data model, contracts, quickstart and the evidence scripts of spec 005; spec revised after the investigation (corners as places, cancelled out-lap rule). --- .../contracts/coach-reading.md | 33 + .../contracts/insights.md | 47 ++ .../data-model.md | 83 +++ .../evidence/buildResearchDb.ts | 89 +++ .../evidence/cornerOptions.ts | 261 ++++++++ .../evidence/inspect.ts | 361 +++++++++++ .../evidence/rawChecks.ts | 88 +++ specs/005-analysis-engine-artifacts/plan.md | 206 ++++++ .../quickstart.md | 41 ++ .../005-analysis-engine-artifacts/research.md | 591 ++++++++++++++++++ specs/005-analysis-engine-artifacts/spec.md | 24 +- 11 files changed, 1816 insertions(+), 8 deletions(-) create mode 100644 specs/005-analysis-engine-artifacts/contracts/coach-reading.md create mode 100644 specs/005-analysis-engine-artifacts/contracts/insights.md create mode 100644 specs/005-analysis-engine-artifacts/data-model.md create mode 100644 specs/005-analysis-engine-artifacts/evidence/buildResearchDb.ts create mode 100644 specs/005-analysis-engine-artifacts/evidence/cornerOptions.ts create mode 100644 specs/005-analysis-engine-artifacts/evidence/inspect.ts create mode 100644 specs/005-analysis-engine-artifacts/evidence/rawChecks.ts create mode 100644 specs/005-analysis-engine-artifacts/plan.md create mode 100644 specs/005-analysis-engine-artifacts/quickstart.md create mode 100644 specs/005-analysis-engine-artifacts/research.md 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..f04a1ac --- /dev/null +++ b/specs/005-analysis-engine-artifacts/contracts/coach-reading.md @@ -0,0 +1,33 @@ +# 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, before the difference phrases: + +- 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; +- one lap did not reach full throttle before the next corner: which one. + +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..82dd927 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/contracts/insights.md @@ -0,0 +1,47 @@ +# 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 | + +## 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..36f1607 --- /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 after the slowest point, 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..2f61b80 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/plan.md @@ -0,0 +1,206 @@ +# 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 +├── recordingCut.json # NEW: the versioned cut (laps 6–8), generated +└── recordingCut.test.ts # NEW: property tests on the cut, size ceiling +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. | §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; **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. +- 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`) and status codes do not change; a pair that was served from the cache can now answer 503 without the key (its insights changed), a status the frontend already handles. + +### 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) + +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..8c26021 --- /dev/null +++ b/specs/005-analysis-engine-artifacts/research.md @@ -0,0 +1,591 @@ +# 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/