Tags Are Evidence Tallies
Stage 8 is where the pipeline reads its own output back. A single extraction pass turns the finished object into a feature set; 44 scoring rules turn features into tag scores. The scores are the game's occluded ground truth: the player's claims are eventually judged against them, and the player never sees them.
Extraction: one pass, one value per question
extractFeatures runs once across components, attachments and decorative layers together, and it's pure and PRNG-free: same artefact in, same features out. An artefact can have three edged components. Which one is "the blade"? Every multi-component collision resolves to one value through a documented collapse policy:
- Blade fields read from the dominant edged component (longest, earliest position on ties), so length band and profile never describe different blades.
- Container fields read from the dominant container (largest hollow-enclosed, falling back to longest cylindrical), so a bowl with a socketed ferrule reads the bowl's opening and base, never a mix.
- Categorical fields like perforation collapse to the most classificatorily loaded value present, with the priority order pinned in comments.
Some features have no single grammar signal and are derived from interviewed anatomy rules instead:
- Impact surface: an untapered bar end or a thick disc. A plain bar end is a striking face; think hammer, pestle, mace head.
- Fastening mechanism: brooch anatomy (a sharp, edgeless pin bearing on a hoop) or any hinged join, gated to body scale.
- Wearable: a ring form or a suspension perforation, gated the same way, because a hinged chest lid is not a dress fastener and nobody wears a perforated slab.
The presence flags deliberately refuse the module's own graceful-degradation contract: unrecognised signals leave them false, so degradation can never fabricate a pin.
portability or inspectionDepth. Both derive from the same dimensions as the physical massBand and sizeBand, but they are mechanical facts about player handling, and letting classification consume them would smuggle game mechanics into world semantics. Rules read the physical bands instead, and a boundary guard test fails the suite if any rule ever touches the mechanical pair.Scoring: a plain sum, on purpose
classifyArtefact runs all 44 rules and folds every matching rule's tag weights into one map by plain unbounded sum, iterated in canonical vocabulary order. No normalisation, no softmax, no cap. The JSDoc pins what the numbers mean: "evidence tallies, not confidences". Consumers compare by rank and margin and read absent tags as zero. An engraved bronze blade scores on weapon, ritual, ceremonial and elite simultaneously, and the system leaves the tension unresolved because resolving it is the player's entire job.
// the rule shape (shipped rules live in data/classification.ts; weights here illustrative)
interface ClassificationRule {
id: string; // stable slug, e.g. 'edge-short-sharp-dagger'
reads: string; // the condition in plain words, shown in the Explorer
concludes: string; // the reading its tags stand for
condition: (features: ExtractedFeatures, context: ClassificationContext) => boolean;
tags: Map<ArtefactTag, number>;
}
{ condition: (f) => f.hasEdge && f.bladeLengthBand !== 'short',
tags: new Map([['weapon', 0.6], ['tool', 0.3]]) }
Absolute tags, relative tags
The vocabulary splits by scoring basis, and the two halves make two different kinds of claim.
| Basis | The claim | Tags | Threshold |
|---|---|---|---|
| Absolute | About the object: affordances a lone examiner could verify with the thing in hand | weapon, tool, container, fastener, ornament, domestic, agricultural, maritime, trade-good, currency | Fixed |
| Relative | About a population: this object sits in the upper tail of its own culture's output | personal, communal, elite, utilitarian, ceremonial, everyday, military, artisanal, ritual, votive, funerary | Sampled per culture-phase |
The failure that forced the split
Under absolute thresholds, the applied-element rule fired on 4.3% of artefacts from an austere culture and 48.1% from a decorative one, an order of magnitude of phase sensitivity; "elite" had quietly come to mean "unusually decorated in absolute terms", so a decorative culture read as composed almost entirely of elites. That breaks Simulation Honesty directly, and the fix follows the tag's semantics: elite means unusually rich for this culture, so the threshold has to come from this culture's own distribution.
Whose distribution?
A population claim means nothing until you say whose. Four populations could anchor it:
- Every artefact in the world: reproduces the measured failure; decorative cultures become aristocracies wholesale.
- The profession's published corpus: ground truth would depend on what happens to have been excavated, and the pipeline guarantees ground truth is lens-independent.
- The player's personal finds: the same problem, with a smaller and stranger sample.
- The producing culture-phase's own output: the anchoring that matches what the word means. Unusually rich for the people who made it.
Archaeologists reason the same way when they read a grave as rich against the local burial record rather than against Tutankhamun.
The baseline is sampled, never authored
The engine generates 400 artefacts through stages 4–7 for the culture-phase, builds percentile ladders over nine sampled features and scores the real artefact against those. There's no bootstrap circularity, because nothing upstream of classification reads a tag. Sampling also keeps the baseline honest across change: a culture's norm is whatever its own generator actually produces, so retuning any upstream weight moves every relative threshold with it at the next sampling, where an authored baseline would silently drift the first time the grammar changed.
// baselines.ts
export const BASELINE_SAMPLE_SIZE = 400; // the measured knee (doc 11 §2.9)
export const SAMPLED_FEATURES: readonly BaselineFeature[] = [
'decorativeLayerCount', 'decorativeComplexity', 'techniqueComplexity',
'appliedElementCount', 'decorativePerPart', 'partCount',
'attachmentDiversity', 'edgeCount', 'meanDecorativeGrade',
];
// a relative rule then asks the context, via ClassificationContext.exceeds,
// whether this artefact clears a sampled rung for one of those features
Thresholds come from samples
Measured, then pinned
The spec's illustrative thresholds (decorativeComplexity > 2 and friends) were authored before any decoration pipeline existed. Once one did, sampling 1200 real artefacts showed those thresholds firing on 87–99% of output; under a plain-sum fold, a rule that fires on nearly everything adds a constant to every score and discriminates nothing. The shipped thresholds are pinned to measured percentiles instead, and the doc carries the distribution table:
| Feature | p50 | p75 | p90 |
|---|---|---|---|
decorativeLayerCount | 6 | 10 | 13 |
decorativeComplexity | 11.2 | 16.3 | 22.3 |
techniqueComplexity | 9 | ||
decorativeComplexity / partCount | 4.05 |
Against those measured medians, the spec's > 2 was firing on essentially everything the real pipeline produces.
The sample itself was wrong once
A later re-measurement found the original 1200-artefact sample skewed: the mock geology modelled only 4 of 16 materials, so the other twelve passed an "unmodelled means obtainable" lenience at full weight, and silver came out the world's second commonest material. Re-sampling 7200 artefacts across six fully modelled fixture worlds corrected the material mix and, usefully, proved the elite inflation (steady at ~90% presence in every world) had never been material-driven in the first place.
Ties inflate fire rates
Sampled features are coarse integers with heavy tie-mass, and the comparison is ≥, so a p75 threshold landing on a tie point admits the whole tie and fires at nearly double the implied rate. Each migrated rule records the realised fire rate it was checked against rather than trusting the rung's name.
Where the migration stands
- 35 of the 44 rules award relative tags, per the split pinned in the test suite.
- Nine of those had numeric conditions a percentile could replace, and all nine are migrated.
- The other 25 read categorical bands (wall thickness, base type, mass band and kin) and stay absolute pending a design spike; what "relative" means for a categorical value is still undecided, and the code declines to guess.
- One tag,
currency, has no rule at all: no grammar signal exists to key on, and it stays absent rather than force-fitted.
src/lib/engine/generation/classification.ts (extractFeatures, classifyArtefact) · rules: src/lib/data/classification.ts · baselines: engine/generation/baselines.ts · vocabularies: src/lib/types/tags.ts · Spec: doc 05 §9