Scoring
Turn rules over data you already hold into a live 0–100 score per company or person, then filter and explain it.
A score turns everything ackDB knows about a record into one 0 to 100 number with a label. Two kinds of evidence feed it: fit, who the record is, and behavior, what it actually does. You choose the criteria and the weights; ackDB computes the number live on every read.
Nothing is stored per record. Edit the definition and every score is instantly the new definition, no backfill.
1. Fit criteria
Fit is qualification: facts about the record that change rarely.
| Criterion | Ref | Points from |
|---|---|---|
| Field match | attribute:tier, trait:industry | Value to points entries (eq, in, present) |
| Has an id | identity_link:stripe_customer_id | Present or not |
| Personas | contacts | Count of matching people, like founders or admins |
| Their company | company:trait:industry (person grain) | A person scored by the company they belong to |
Match values on allowed-values fields are canonicalized case-insensitively, so SaaS and saas are the same answer. A record missing the field simply contributes 0 points; nothing is guessed.
2. Behavior criteria
Behavior is what the record does, always inside a trailing window so old activity ages out on its own.
| Criterion | Shape | Points from |
|---|---|---|
| Event volume | event:slack.message + windowDays | Count, sum of a field, or distinct contacts, through bands or per-unit points with a cap |
| Multi-source interest | convergence + sources or signals + windowDays | How many distinct sources (or named signals) fired; breadth beats volume from one channel |
| Current value | event:usage.daily_rollup + agg: "latest" + valueField | The newest event's promoted field, banded |
Behavior edge cases, stated because they bite:
windowDaysruns 1 to 365. Optional half-life decay makes older events count for less inside the window.- Event metadata matching is exact and case-sensitive, unlike fit fields; pick values from the field catalog rather than free-typing.
- A
latestcriterion with no matching event, or a non-numeric value, scores 0. It never falls into altband, so missing data cannot masquerade as "low".
3. Create a score
One fit group and one behavior group, weighted 40/60:
In this definition:
grainiscompanyorperson, immutable after create.- Event refs are wire-bare (
event:slack.message); the server resolves stored names. - Every point value is bounded to plus or minus 100, and per-unit slopes are capped, so no single criterion can run away with the score.
- Every ref is validated at create time: an unknown field is a 400 naming the criterion, never a definition that silently scores zero.
4. Normalize and label
normalization: "absolute" (default) scores against the maximum possible points. "percentile" ranks the record against your own population instead, which is the honest choice when "good" means "better than our other accounts"; percentilePopulation: "active" ranks only rows that scored at all, so empty rows stop being the yardstick.
labelBands turn numbers into words, first match wins:
The result serves as two columns everywhere: sc__engagement and sc__engagement__label. One definition per grain can be isPrimary, which is what profiles surface by default.
5. Scope a score to a segment
scopeSegmentId targets a definition at one saved segment: score only trial accounts, or only one book of business. Rows outside the scope read null, never 0; a zero would take the lowest label band and pollute every sc__x < 40 filter. Scoped number columns therefore also filter on is_null and is_not_null.
Any segment grain can scope any score grain. The one structural rule: a scope segment's filter may not reference a scoped score, which makes scoring cycles impossible.
6. Preview, explain, capabilities
Tune before saving: POST /scores/preview compiles an unsaved draft and returns the live population's distribution, the histogram you set bands against.
After saving, every row explains itself:
The response walks each criterion: what it looked at, what it found, and the points it contributed, including which sources or signals fired for convergence and the as-of event behind a latest value. GET /scores/capabilities publishes the exact grammar the validator accepts, so builders render from the contract instead of probing.
7. Track history
Live scores answer "now"; history answers "since when". A nightly sweep appends change-only score.changed events: first observation, a label flip, or drift of at least half a point since the last emitted value.
Read it with GET /entities/piedpiper.com/scores/engagement/history: fill=changes for the change points, fill=locf to carry values forward for charting. A label flip is your tier-crossing signal. History starts at the first sweep after a definition exists; the past was never observed under it, so there is no retroactive backfill.
8. Good to know
- Deleting a source scrubs its criteria out of definitions and flags them
degraded, visible on reads, rather than silently changing scores. - Deleting a scope segment disables its scoped definitions and flags them
degraded; history emits exits so charts stop cleanly. - A definition edit changes live scores immediately; history records the definition hash alongside, so a jump is attributable to the edit.
- Negative criteria are allowed (points down to minus 100); the final column clamps at 0.