# intelligence

## Purpose

Pure analysis library for computing intelligence insights from run data. Takes run snapshots as input and produces regression/gain/opportunity insights plus health metrics. No database access, no side effects — pure functions only.

## Key Files

| File | Role |
|------|------|
| `src/analyzer.ts` | `analyzeRuns()` — main entry point, orchestrates all analysis |
| `src/regressions.ts` | Detects queries that lost citation between runs |
| `src/gains.ts` | Detects queries that gained citation between runs |
| `src/health.ts` | Computes overall and per-provider citation health metrics |
| `src/causes.ts` | Root cause analysis for regressions (competitor displacement, etc.) |
| `src/insights.ts` | Transforms raw analysis into user-facing insight objects |
| `src/observation-coverage.ts` | `observedKeys(run, keyOf)` — what a run actually measured, so a missing snapshot row is never read as "not cited". See "Absence is not a negative observation" below. |
| `src/insight-severity.ts` | `classifyRegressionSeverity({ gscImpressions, recurrenceCount })` — pure tiering rule. Caller supplies the signals (lookups happen in `IntelligenceService`); rule lives here so the dashboard, CLI, and Aero classify identically. |
| `src/insight-grouping.ts` | `groupInsights<T>(insights, keyFn?)` — generic dedup over `(query, provider, type)`. Consumed by report renderer + any future CLI/dashboard list view to collapse repeat alerts. |
| `src/next-steps.ts` | `mapOpportunitiesToNextSteps()` — auto-fills `recommendedNextSteps` from scored content opportunities when the upstream insight-driven builder produced none. Pure mapper consumed by both `api-routes/report.ts` and `canonry/report-renderer.ts`. |
| `src/query-categorize.ts` | `buildBrandTokens()` + `categorizeQueryByIntent()` — brand/lead-gen/industry/other classifier. Uses contracts' exact approved-alias matcher across spacing/hyphenation variants; suffix stripping and fuzzy/edit-distance attribution are forbidden. |
| `src/trend-stability.ts` | `isTrendBaseline(points)` + `MIN_TREND_POINTS` — predicate any consumer (renderer, dashboard tile, Aero) calls before showing a trend chart. Suppresses misleading visualizations on small samples. |
| `src/types.ts` | Shared types: `RunData`, `Snapshot`, `AnalysisResult`, `Insight` |
| `src/index.ts` | Barrel re-export of all modules |

## Patterns

### Usage

```typescript
import { analyzeRuns } from '@ainyc/canonry-intelligence'
import type { RunData, AnalysisResult } from '@ainyc/canonry-intelligence'

const result: AnalysisResult = analyzeRuns(currentRun, previousRun)
// result.regressions, result.gains, result.health, result.insights
```

### Design principles

- **No I/O**: This package never touches the database, network, or filesystem. Callers provide `RunData`, receive `AnalysisResult`.
- **Deterministic**: Same inputs always produce the same outputs. No randomness, no timestamps.
- **Consumed by**: `IntelligenceService` in `packages/canonry/` which handles DB reads/writes.

### Absence is not a negative observation (Critical)

`RunData.snapshots` records what a run OBSERVED, not what is true. A provider
call that throws writes no snapshot row at all — that is what `status='partial'`
means — so a sweep can be missing whole (query × provider) pairs. **A missing
row must never be read as "not cited".**

Any detector that claims a TRANSITION ("started being cited", "dropped off")
has to confirm the relevant side observed the thing before claiming it moved.
Use `observedKeys(run, keyOf)` from `observation-coverage.ts`, keyed the same
way that detector keys its cited set. Which side depends on which direction the
claim runs: a GAIN or pickup is claimed from absence in the baseline, so the
baseline must have observed it; a LOSS is claimed from absence in the current
run, so the current run must have.

Measured against a baseline sweep where one provider errored, skipping this
produced 6 false insights for a site where nothing had changed.

`detectRegressions` and `detectPersistentGaps` are correct by construction —
the first iterates the current run's rows and requires a prior CITED
observation, the second breaks a streak on any run missing the query.
`packages/intelligence/test/observation-coverage.test.ts` covers every detector
in both directions: the hole is suppressed AND the real transition still fires.
Add a new detector to that suite.

## See Also

- `packages/canonry/src/intelligence-service.ts` — DB integration layer that calls `analyzeRuns()`
- `packages/contracts/src/intelligence.ts` — DTOs for API/CLI consumers (`InsightDto`, `HealthSnapshotDto`)
