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.

fit · who they areindustry = saastier = strategiccontacts: founders ≥ 1behavior · what they doslack.message · last 30dinvoice.paid · last 90dconvergence · ≥ 3 sources× 0.4× 0.6normalize 0–100the columnsc__engagement = 78label: "high"absolute, or percentile againstyour own population
Fit says who they are; behavior says what they do. Weighted together, normalized, labeled, computed on every read.

1. Fit criteria

Fit is qualification: facts about the record that change rarely.

CriterionRefPoints from
Field matchattribute:tier, trait:industryValue to points entries (eq, in, present)
Has an ididentity_link:stripe_customer_idPresent or not
PersonascontactsCount of matching people, like founders or admins
Their companycompany: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.

CriterionShapePoints from
Event volumeevent:slack.message + windowDaysCount, sum of a field, or distinct contacts, through bands or per-unit points with a cap
Multi-source interestconvergence + sources or signals + windowDaysHow many distinct sources (or named signals) fired; breadth beats volume from one channel
Current valueevent:usage.daily_rollup + agg: "latest" + valueFieldThe newest event's promoted field, banded

Behavior edge cases, stated because they bite:

  • windowDays runs 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 latest criterion with no matching event, or a non-numeric value, scores 0. It never falls into a lt band, so missing data cannot masquerade as "low".

3. Create a score

One fit group and one behavior group, weighted 40/60:

POST /scores
{
  "scoreKey": "engagement",
  "name": "Engagement",
  "grain": "company",
  "groups": [
    { "key": "fit", "weight": 0.4 },
    { "key": "behavior", "weight": 0.6 }
  ],
  "criteria": [
    {
      "group": "fit",
      "field": "attribute:tier",
      "match": [{ "op": "eq", "value": "strategic", "points": 100 }]
    },
    {
      "group": "behavior",
      "field": "event:slack.message",
      "windowDays": 30,
      "bands": [
        { "gte": 20, "points": 100 },
        { "gte": 5, "points": 60 },
        { "lt": 5, "points": 0 }
      ]
    }
  ]
}

In this definition:

  1. grain is company or person, immutable after create.
  2. Event refs are wire-bare (event:slack.message); the server resolves stored names.
  3. 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.
  4. 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:

"labelBands": [
  { "gte": 70, "label": "high" },
  { "gte": 40, "label": "medium" },
  { "lt": 40, "label": "low" }
]

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:

GET /entities/piedpiper.com/scores/engagement/explain
curl -s "$ACKDB_URL/entities/piedpiper.com/scores/engagement/explain" \
  -H "Authorization: Bearer $ACKDB_API_KEY"

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.