Documentation source
Response System
Core documentation for Amble's response system, including edge scoring and scoped criteria sets.
# Response System
The response system allows users and agents to capture structured feedback, assessments, and data points against entities.
## Scoped Criteria Sets (Edge Scoring)
Amble supports **Scoped Criteria Sets**, which allow you to score or evaluate relationships (edges) connected to an entity, rather than just the entity itself. This is often referred to as "Edge Scoring."
### Concept
By default, a criteria set has an entity scope (`{ type: "entity" }`), meaning the dimensions apply directly to the primary record.
With a relation scope (`{ type: "relation", fieldKey: "target_markets" }`), the evaluation is distributed across the entities connected to the primary record via the specified `fieldKey`.
When a scoped criteria set is submitted:
1. The `values` payload is a nested matrix keyed by the connected entity ID: `{ [connectionId: string]: { [dimensionKey: string]: value } }`.
2. The system resolves all target entities and validates that the dimensions match the criteria set.
3. Scores are computed as the **mean** across all evaluated edges. A 100 on one connection and a 50 on another results in an overall normalized score of 75.
### UI Integration
- **EdgeMatrixForm**: Scoped criteria sets are rendered using a matrix interface where each row represents a connected entity.
- **MatrixAggregationView**: Relation-scoped responses aggregate into a per-connection matrix view — a `BubbleChart` plots each connection on two numeric dimensions (X/Y with optional Z bubble size), backed by a sortable leaderboard that shows each connection's aggregate per dimension plus a weighted score. Users pick the aggregation strategy (mean / median / min / max), toggle a respondent-dot overlay, and everything persists in the URL so chart views are shareable.
- **Compare Views**: Still a fallback for now — captured as a follow-up.
- **Promotions**: Scoped responses cannot be directly promoted to the primary entity, as they represent data about the relationships, not scalar fields on the entity itself.
### Matrix aggregation internals
- `computeResponseAggregate()` in `features/responses/lib/aggregate-matrix.ts` is the pure aggregation function. It walks each response's per-connection value map, collects numeric values per dimension, applies the strategy, and computes a weighted/normalized score per connection using the criteria set's weights and scales. Both the server's `aggregateResponses()` and the client's `MatrixAggregationView` call this fn so strategy switches update without a server round-trip.
- `ResponseAggregation.byConnection` is an additive field on the server's aggregate payload. Entity-scope responses leave it undefined; relation-scope responses populate it.
- The `BubbleChart` component was extended with an optional `overlayData` prop — a second `<Scatter>` layer rendered underneath the primary dots at reduced opacity for the respondent overlay.
### URL state keys for MatrixAggregationView
`x=<dim>` `y=<dim>` `z=<dim>` `agg=mean|median|min|max` `overlay=1` `sort=<dim|score|title>` `dir=asc|desc` — all optional; defaults are the first two numeric dimensions, mean aggregation, overlay off, and score-desc leaderboard sort.
### Edge scoring in practice — DOC'S visit ↔ protocol
The DOC'S tenant uses relation-scoped criteria sets to score per-protocol outcomes inside a single clinic visit. The pattern illustrates all three edge-scoring invariants in a production context.
**Shape.** `visit` declares a multi-relation field `protocols` (relationship type `did-protocol`). The criteria set `visit-protocol-response` carries scope `{ type: "relation", fieldKey: "protocols" }` with two dimensions — `rating` (1–5) and `note` (text) — both `required: false`. One matrix response row is submitted per visit: `values: { [protocolEntityId]: { rating, note } }`. Partial submissions (only some protocols rated) are valid; zero-scored submissions write nothing.
**Writers.** `logVisitSessionAction` writes `did-protocol` edges on the created visit and then calls `submitVisitProtocolResponse`, which reaches `insertEntityResponse` so `promoteEdgeScores` fires automatically. Plan-activation visit generation (`generate-plan-visits`) prefills edges from the projected sequence step using the same writer.
**Cache vs truth.** `entity_relations.metadata.scores` blobs are a derived cache populated by `promoteEdgeScores`. The protocol Outcomes surface (ranking visits by outcome) aggregates from raw `entity_responses` rows rather than blobs — blobs are used only for `getRankedConnections` sort order. A missing blob is a transient state; the repair job heals it without data loss.
## Claim aggregates (source-item → claim rollup)
For graphs where source-items evidence claims (DOC'S and any tenant that reuses the pattern), promoted evidence-quality responses roll up onto every supporting claim as two denormalized keys on `entities.content`:
- `source_count` — number of unique source-items supporting the claim
- `source_quality_avg` — mean of per-source-item dimension means
### Data flow
1. A user or agent promotes a source-item's `evidence-quality` response → Supabase emits `response.promoted`.
2. `recomputeClaimAggregatesOnResponse` (Inngest listener) slug-gates on the source-item entity type + `evidence-quality` criteria set.
3. `step.run("list-supported-claims", getSupportedClaimIds)` resolves the claim set for that source-item.
4. `step.run("fanout", () => inngest.send([...]))` emits one `claim.aggregate.recompute` event per claim.
5. `recomputeClaimAggregateForClaim` consumes each event with per-claim concurrency limit 1. It calls `recomputeSingleClaimAggregate`, which re-derives `source_count` / `source_quality_avg` and writes via the `public.apply_claim_aggregate` RPC.
### Concurrency contract
`recomputeClaimAggregateForClaim` declares:
```ts
concurrency: [
{ limit: 10 },
{ limit: 1, key: "event.data.tenant_id + ':' + event.data.claim_id" },
]
```
The per-claim limit-1 tier is the correctness contract — two source-items that land on the same claim inside the same Inngest window are serialized end-to-end (read → derive → write), which prevents the stale-aggregate race that burst traffic previously surfaced. The global cap protects Postgres from unbounded write pressure. Inngest SDK 3.x caps the tuple at 2, so a per-tenant fairness tier is tracked as a follow-up.
### Atomic write invariant
`public.apply_claim_aggregate(p_tenant_id, p_claim_id, p_source_count, p_quality_avg)` performs a single row-locked `UPDATE entities SET content = content || jsonb_build_object('source_count', ..., 'source_quality_avg', ...)`. This eliminates the cross-key clobber that the previous TypeScript read-merge-write suffered — any other writer touching `content` between the SELECT and UPDATE is no longer silently overwritten. The function is SECURITY DEFINER with empty `search_path`; `EXECUTE` is revoked from `authenticated` + `anon` and granted only to `service_role`, so direct PostgREST invocation from a user session is impossible. A 0-row result (deleted claim or cross-tenant id) is a graceful no-op — `recomputeSingleClaimAggregate` returns `{ updated: false }` without throwing and Inngest does not retry.
### PR #842 dedupe invariant
`recomputeSingleClaimAggregate` preserves the `newestByEntity` Map dedupe pattern from PR #842: when a source-item has multiple promoted responses for the same criteria set (re-promotion after reviewer correction), the newest row wins. Counting every promoted row would double-count that source-item in the claim average. Covered by a dedicated test case in `recompute-single-claim-aggregate.test.ts`.
### Generic across tenants
The listener keys on tenant-configurable slugs via `getSourceItemEntityTypeSlug()` and `getEvidenceQualityCriteriaSlug()` (honoring `SOURCE_CHILD_TYPE_SLUG` / `EVIDENCE_QUALITY_CRITERIA_SLUG` env overrides). Tenants that use different slugs for the same pattern can set those envs; tenants that use a fundamentally different graph shape can wire their own listener and call `recomputeSingleClaimAggregate` / `apply_claim_aggregate` directly.