# The seven signals — what each wants, in what unit Every need carries seven signals. Four are **need-specific**, asked or voted per need. Three are **group-wide telemetry**, written identically onto all 41 needs, so they say nothing about any one of them — which is why coverage counts the need-specific four only. Source of truth (in **hoen-assessment**): `apps/hoen-explorer/src/snapshots/normalisation.ts`. ## Need-specific — go in `needs[""]` | Field | Scale | Direction | Where it comes from | |---|---|---|---| | `survey` | 0–10 | higher = healthier | **Don't write this by hand.** Supply `questions` instead and the rollup computes it. An inline value is only read when no question matched. | | `maturity` | 0–3 | higher = healthier | SME self-rating in the workshop. Whole or half steps are normal. | | `spendRank` | 1–10, or a longer stack | **inverted** — 1 scores 0, the stack maximum scores ~100 | Stack rank over current spend. **Rank 1 = the most current spend is already going in**, which the model reads as a live, funded gap. Getting this backwards inverts the signal. The default band is 10; a workshop stack that runs longer is fine and the group's own maximum is used instead. | | `spendAmount` | any number | inverted by rank | Optional. Raw dollars. Used when **every** need in the file carries one *and* `spendRank` stays inside the 1–10 band — then the canonical stack rank replaces the rank column. Partial coverage is ignored; a stack running past 10 keeps `spendRank` spacing. | | `workshopDotVote` | 0–1 | **inverted** — a need that soaked up votes scores low | Share of the workshop's dots that landed on this need. Dots mark where the experts think the problems are. | `spendRank` and `workshopDotVote` both invert, and that is the most common intake error. A need everyone voted for is a need in trouble, so it scores *low* — the same direction as every other signal, where 100 means healthy. ### The spend stack: transcribe the park bucket too This is the one place where writing down a rank you were told, rather than omitting it, is the correct move. Facilitators stack **all 41 needs** and park the ones nobody discussed on a shared bottom rank, often with a leftover `$1`. The engine uses that shared bottom rank to *detect* what was never discussed and drop it from spend scoring altogether: | What the file carries | What the engine does | |---|---| | Every need has a `spendAmount`, and 2+ needs share the bottom rank at the same leftover amount | Needs above the leftover floor are scored; the parked tail is dropped | | Ranks only, stack maximum ≤ 10 | Every ranked need is scored | | Ranks only, stack runs past 10 | The shared bottom rank is treated as the park bucket and dropped | So transcribing only the funded top ten and omitting the rest is worse than useless: the other 31 needs default to rank **5.5**, the stack maximum falls to 10, and 31 mid-stack allocations nobody ever made are scored as real. Before this rule existed, reading the parked tail as "well funded" made unspoken needs outscore each group's actual top priority. ### Dot votes: share, not count Given counts, divide by the total dots cast across all needs, so the values sum to roughly 1 for the group. A raw count — `7` — clamps to 1 and reads as "every dot landed here". ## Group-wide telemetry — goes in `rawMetrics` The `rawMetrics` block must be present or **the file is rejected outright**. Use `{}` when there is no telemetry — that is an honest empty block, not an error. | Field | Unit | Feeds | Notes | |---|---|---|---| | `leadTimeHours` | **hours** | CTP (0.6) | Commit to running in production. Curve is meaningful over ~1–60h. | | `deployPerDay` | **per day** | CTP (0.4) | Scored against a target of one deploy per two active contributors per day — so it needs `totalActiveContributors` to mean anything. | | `totalActiveContributors` | people | CTP | Defaults to 4 if absent, which will flatter a large group. | | `teamsCount` | teams | CQA, QoS | Incident counts are compared **per team**. Absent = treated as one team, which will make a multi-team group look far worse. | | `changeFailPct` | **percent, 0–100** | CQA (0.3) | Not a 0–1 fraction. Clamped to 0–20 by the curve. | | `recoveryTimeHours` | **hours** | CQA (0.3) and QoS (0.15) | MTTR after a failed change. Note the model's measure table labels this in minutes; **the snapshot field is hours**. | | `incidentsLowPerMonth` | per month | CQA (0.1) | Low-severity, divided by `teamsCount`. | | `incidentsMedHighPerMonth` | per month | QoS (0.4) | Customer-affecting, divided by `teamsCount`. | | `waitTimePct` | **percent, 0–100** | CTP (0.2) | Waiting duration ÷ total elapsed duration × 100, over a stated commitment-to-customer boundary. Steep between 60% and 85%. **Absent on current intake** — supply a measured or explicitly estimated reading as an override in the Assessment data cards. | | `releaseConfidence` | **0–10** | CQA (0.3) and QoS (0.45) | How confident the team is a release lands cleanly. Higher is better. Manual until the Likert items are wired; **absent on current intake**, and its weight redistributes when missing. | Two traps worth repeating. Both produce a plausible score rather than an error: - **`recoveryTimeHours` is hours.** A dashboard reporting MTTR in minutes needs dividing by 60. 45 minutes entered as `45` reads as nearly two days. - **`changeFailPct` is 0–100.** An SDO dashboard reporting `0.12` becomes `12`. ### Wait-time provenance The percentage is not reproducible without its collection context. Record: - start and end boundary; - date range and number of work items; - included work types and exclusions; - workflow states classified as active or waiting; - waiting duration and total elapsed duration; - any correction factor, its basis and who agreed it. Prefer a same-group trend over a cross-group comparison. Different work types, workflow conventions and status discipline can make two identical percentages mean different things. A benchmark or target is not a missing reading and must not be entered as a raw override. ## What happens when something is missing Nothing errors. `ingest` fills the gap: | Missing | Filled with | |---|---| | `survey` (no matched question, no inline value) | 5 / 10 | | `maturity` | 1.5 / 3 | | `spendRank` | 5.5 | | `workshopDotVote` | 0.5 | | a telemetry field | the signal is scored from whatever fields remain; a signal with no fields at all is absent | A defaulted need scores like a measured one. Hence **omit, never guess** — an omission shows on the coverage meter, an invented 1.5 does not.