Stage 8 · Features & classification

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:

Some features have no single grammar signal and are derived from interviewed anatomy rules instead:

The presence flags deliberately refuse the module's own graceful-degradation contract: unrecognised signals leave them false, so degradation can never fabricate a pin.

A boundary with a test on it No classification rule may read 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]]) }
engraved bronze blade: accumulated tallies (illustrative values) weapon1.1 elite0.9 ritual0.8 ceremonial0.7 tool0.3 no winner is declared; rank and margin are the signal, and the player never sees these numbers
A plain sum leaves the argument open. Four high tallies on one object is a puzzle, and puzzles are the product.

Absolute tags, relative tags

The vocabulary splits by scoring basis, and the two halves make two different kinds of claim.

BasisThe claimTagsThreshold
AbsoluteAbout the object: affordances a lone examiner could verify with the thing in handweapon, tool, container, fastener, ornament, domestic, agricultural, maritime, trade-good, currencyFixed
RelativeAbout a population: this object sits in the upper tail of its own culture's outputpersonal, communal, elite, utilitarian, ceremonial, everyday, military, artisanal, ritual, votive, funerarySampled 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:

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
culture-phaseprofile + geology 400 artefactsstages 4–7, no tags percentile ladderper sampled feature exceeds p75?award the relative tag the real artefact is scored against its own culture's ladder, so "elite" always means "elite here"
Baseline sampling. The sample runs the same pipeline as real artefacts, which is what keeps the norm current.

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:

Featurep50p75p90
decorativeLayerCount61013
decorativeComplexity11.216.322.3
techniqueComplexity9
decorativeComplexity / partCount4.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

Where it lives 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