Design: IPIP-anchored sentiment & trait scoring
Context
📝 ADR-0028 decided the
taxonomy: a curated two-tier subset of the public-domain IPIP construct pool
(five Big Five domains + ~10 affect facets), scored by the configured chat
model with IPIP marker items as prompt anchors, stored in the hash-keyed,
model-stamped table that SPEC-0017 REQ-0017-010
reserved. This design covers how the lexicon is built, how scoring runs, and
how the three consumer surfaces read the table. The implementation template is
internal/facts (📝 ADR-0011):
same cursor, same egress posture, same defensive parsing stance.
Goals / Non-Goals
Goals
- One sparse table that any future affect/trait surface can aggregate at read time without new scoring runs.
- Scoring stable enough to survive prompt edits and model swaps re-derivably
(
model+lexicon_versionstamps; rescan on change). - Privacy at least as strong as facts: exclusion before read, retroactive per-contact opt-out, single egress, deliberate command.
Non-Goals
- Psychometric validity. IPIP validation applies to self-report inventories; we borrow its constructs and anchors for LLM judgment consistency, and every surface says so.
- Web-triggered scoring, MCP exposure, non-English anchors, score curation UI (all deferred; see spec Scope).
Decisions
Lexicon: a small committed Go table over an embedded CSV
Choice: internal/sentiment embeds the normalized IPIP item table
(ipip_items.csv, columns instrument,alpha,key,text,label, attribution
header) and defines the lexicon in Go: an ordered list of constructs, each
with its tier (domain/affect), the table label(s) it draws anchors from,
and the anchor-selection rule. lexiconVersion is a package constant
("v1").
Rationale: the full table is data (3,805 rows, public domain — embed it
verbatim, attributable and diffable); the curation is code (15 entries — a Go
literal is type-checked, testable, and needs no config-file parsing). Bumping
curation is a code change that must bump lexiconVersion, which a test
enforces by hashing the lexicon definition.
Anchor selection: per construct, take marker items from assignments with
alpha ≥ 0.75, dedupe identical item texts across instruments, prefer the
highest-alpha assignment for keying, and keep up to 6 anchors — at least one
positively and one negatively keyed (REQ-0027-001 fails the build otherwise).
Domain constructs may draw from sibling labels (e.g. Intellect/Openness draws
from Intellect and Intellectual Openness); affect constructs map 1:1 to
their table label.
Alternatives considered:
- YAML lexicon file: parseable at runtime, but adds a parser + validation for 15 static entries; rejected.
- Runtime queries over the full table: pointless generality — the lexicon is the API; the table is an implementation detail of building it.
Score semantics: signed, sparse, salience-gated
Choice: the model returns, per message, only constructs with salient evidence, scored in [−1, +1]; the engine clamps out-of-range values, drops unknown constructs, and discards |score| < 0.2 before storage.
Rationale: sign carries direction on bipolar domain axes (reserved ↔ gregarious); affect facets live mostly in the positive range. The salience floor keeps "ok, see you at 6" from producing rows — the table stays proportional to expressive content, not corpus size.
Batching and attribution
Choice: conversations are scored in keyset-ordered batches (mirroring
facts' batch walk); each message is scored independently but with the batch as
conversational context; contact_id is denormalized at scoring time as the
message sender's resolved contact (including the owner), and ts_unix is
copied from the message.
Rationale: sender attribution is what both consumer surfaces need (the
profile shows the contact's expressed affect; the journal aggregates
everyone's). Denormalizing avoids joining through messages at read time,
which also keeps aggregates working for scores whose message row is
mid-re-ingest. Batch-as-context lets the model read sarcasm and quoted text
without scoring the batch as a unit.
Read-side filtering to the current (model, lexicon)
Choice: aggregates read only rows matching the currently configured model
and shipped lexiconVersion. Stale rows from prior models/lexicons are
retained until --reset.
Rationale: mixing scores from different models in one average is
meaningless. Retention keeps a model rollback cheap (the old rows are still
there); --reset is the explicit "reclaim disk" lever, same trade facts
accepted.
Opt-out lives on the contact profile, deletes in-transaction
Choice: a contact_sentiment_optout(contact_id) table; the profile page
hosts the gated toggle (checkSetupPOST, fixed-enum banner, boosted partial
re-render); opting out deletes the contact's rows and inserts the marker in
one transaction; the scoring engine skips opted-out senders' messages before
content is read.
Rationale: the profile is where the user is looking at a person and
decides "not this one" — a Settings list of every contact would be heavier and
farther from the moment of decision. Deletion (not suppression) is the honest
reading of opt-out for a dossier-shaped feature; re-opting-in rebuilds on the
next run because cursors are per-conversation, so the engine detects the gap
via missing rows only on --reset — acceptable: opting back in is documented
as "takes effect for future runs; use --reset to rebuild history."
Alternative considered: a column on contacts — rejected; merge/split
(<a href="/specs/web-ui-redesign/spec#spec-0018" className="rfc-ref">SPEC-0018</a>) moves identities between contact rows, and a separate marker
table keyed by canonical contact id keeps the merge engine untouched.
Consumer surfaces are aggregates + templates, no new pages
Choice:
- Profile sentiment-over-time: month-bucketed mean intensity per affect facet
(
strftime('%Y-%m', ts_unix, 'unixepoch')), rendered with the existing sparkline pattern (presentational-attribute SVG +aria-labeltext alternative). - Profile trait sketch: mean signed score per domain, all-time, rendered only at ≥ 50 scored messages (REQ-0027-008), as labeled text+bar rows — no radar chart (color-alone/legend problems, and five rows read fine).
- Journal mood strip: per-UTC-day mean intensity per affect facet on the day view, additive to the existing rollup card.
Rationale: every surface is one SQL aggregate over message_sentiment
plus template work inside existing swap units — contact_content and the
journal day view keep their SPEC-0008/0017 partial contracts, and the
contact-scope predicate is untouched, which is exactly the seam REQ-0017-010
promised.
Architecture
Risks / Trade-offs
- Dossier sensitivity — trait profiles of real people. → Local-only, no MCP exposure, retroactive opt-out that deletes, AI-generated + "expressed-not-assessed" labeling on every surface; the opt-out is in the first migration, not a follow-up.
- First-run cost on a big archive — the widest-egress extraction yet. → Incremental cursors, sparse salience-gated output, batch-level resume; document that the first run is long and interruptible.
- Model quality variance — a weak local model scores noisily. → Anchored
prompts narrow the variance; the
modelstamp keeps distributions from mixing; thresholds (salience floor, 50-message sketch minimum) suppress thin evidence rather than rendering it. - English-only anchors — non-English threads score worse, silently. → Documented limitation in the UI disclaimer text; translated lexicons are an explicit non-goal until wanted.
- Table growth (rows ≈ expressive messages × salient constructs, per
model/lexicon generation). → Sparse storage bounds the common case;
--resetreclaims stale generations.
Migration Plan
One schema migration (next version after current schemaVersion) adding the
three tables above; FK-less by design, so it composes with re-ingest exactly
like v4 (facts). Greenfield feature otherwise — no data backfill; the table
populates on the first msgbrowse sentiment run. Rollback is dropping the
tables; no other subsystem writes or reads them.
Open Questions
- Should the owner's own messages feed a "you" mood surface (the journal already aggregates them implicitly)? Deferred until the journal surface has real usage.
- Does
sync(SPEC-0007) eventually chainsentimentafterfactsas an opt-in step? Deliberate-command posture says not by default; revisit once first-run cost is measured. - Whether the affect tier wants per-facet toggles (score Anxiety but not Depression) — punted; the lexicon is all-or-nothing in v1.
Testing
internal/sentiment: lexicon build (anchor coverage per construct, loud failure on label drift, version-hash guard), prompt assembly (anchors + keying present), response parsing (fence stripping, clamping, unknown- construct drop, salience floor, malformed-entry skip).internal/store: migration lineage (existing pattern inlineage_test.go), upsert idempotency, cursor resolve/restart semantics, model/lexicon-change rescan trigger, opt-out delete+marker transaction, month/day aggregate bucketing (UTC day rule against straddling-midnight fixtures, per 📝 ADR-0023).internal/cli:sentimentcommand wiring,--reset, non-zero exit on aborted run (mirroringfacts.gotests).internal/web: profile surfaces (empty states, 50-message threshold, disclaimer presence, SVG presentational-attributes +aria-label, boosted-partial contract unchanged), journal mood strip (absent without scores, day-bucket alignment), opt-out POST gating (403 without token, fixed-enum banner) — race detector on, as the suite already runs.
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's ADR-0023 / SPEC-0018 frontmatter-graph conventions). Run /sdd:graph chain SPEC-0027 for the transitive view.