@overpunch/cedars-engine
v8.0.0
Published
Compute a font's **CEDARS+** typographic profile — **C**ontrast, **E**nergy, **D**etails, **A**xis, **R**hythm, **S**tructure — directly from its outline geometry. Framework-agnostic; runs in the browser and in Node. The profile is modelled (since v3) as
Readme
@overpunch/cedars-engine
Compute a font's CEDARS+ typographic profile — Contrast, Energy, Details, Axis, Rhythm, Structure — directly from its outline geometry. Framework-agnostic; runs in the browser and in Node. The profile is modelled (since v3) as continuous scales + a stress angle + categorical facet classifiers.
v6 in one line: Rhythm now means what CEDARS means by it. The spacing-regularity scale was called
patternand the stem-width scale was calledrhythm, which pointed the letter R at the wrong measurement. Regularity is nowrhythm; width is nowwidth, under the +. No geometry changed and no landmark moved — see The six letters, and the plus and The two-version discipline.
v5 in one line: scores are not clamped to 0–100. Each scale's 0 and 100 are two named landmarks — reference archetypes, like the 0 °C and 100 °C of water — so a hairline Didone honestly scores 167 and an ultra-condensed face −139. See Landmarks, not limits.
The measurements themselves (redesigned in v4 for accuracy) are unchanged in v5 and v6:
- Contrast — perpendicular-corrected p95/p5 ring thickness around the de-slanted bowl (no more phantom stress on squarish monolines).
- Energy — springing suddenness + springing height of the stem→arch transition, empirically calibrated on real fonts (the old counter-ratio was weight-noisy and direction-ambiguous; the refutation history is documented in
src/metrics/energy.ts). - Width — de-slanted stem-centerline distance ÷ x-height (centerlines, not outer edges, so weight doesn't read as width). Signed about a normal-proportion zero.
- Rhythm — evenness of the sidebearing budget within structural classes (round letters are designed tighter than straight ones — that's rhythm working, not irregularity); monospace → n/a, not a nonsense score.
- Aperture — terminal-endpoint distance ÷ counter height; a genuinely closed
creads 0, not unavailable. - Axis — closed-form second-harmonic fit of the whole ring-thickness series (sub-degree, vs the old 2° argmin grid), with a graded
stressConfidence.
CEDARS+ is I Love Typography's classification system. CEDARS™ is ILT's, and the name is used here with their permission — this package is an independent implementation that reads a profile from outline geometry and does not reproduce ILT's own scores or data. It is the shared engine behind the CEDARS Analyzer app and the @overpunch/sanity-font-manager auto-tagging step.
Install
npm install @overpunch/cedars-engineopentype.js is bundled as a runtime dependency and installs automatically.
Three kinds of quantity, not six scales
CEDARS descriptors are not all sliders. From I Love Typography's own taxonomy they split into three kinds, and the engine (since v3) models each honestly instead of forcing everything onto a 0–100 scale:
- Continuous scales (landmark-anchored, unbounded): Contrast, Energy, Width (proportion — signed), Rhythm (spacing regularity), Aperture (open ↔ closed).
- An angle: Axis (stress orientation), as an axial angle mod 180° with an explicit monoline / "no stress" state — a monoline face has no stress direction, so it never collapses onto "angle 0" the way v2's signed magnitude did.
- Categorical facets (soft classifiers, each a probability distribution): Loops (7 named bowl shapes), Terminals, Transitions, Fill, Construction. A triangle no longer reads as "near" a rectangle on a 1-D fill ratio.
The six letters, and the plus
CEDARS is six letters plus room beyond them. This engine measures some of that geometrically, and which measurement serves which letter is exported as data rather than left to prose, so a consumer can present a profile under the letters a type designer already knows:
import { CEDARS_LETTERS, CEDARS_PLUS_SCALES } from "@overpunch/cedars-engine";| Letter | Scales | Facets | Other |
|---|---|---|---|
| C Contrast | contrast | — | — |
| E Energy | energy | — | — |
| D Details | — | terminals, transitions, fill | — |
| A Axis | — | — | the stress angle (neither scale nor facet) |
| R Rhythm | rhythm | — | — |
| S Structure | aperture | loops, construction | — |
| + | width | — | plus.xHeightRatio, plus.capHeightRatio, plus.isMonospaced |
Why Width sits under the plus. Proportion is not one of the six letters. Through v5 the engine
called it rhythm anyway, which left CEDARS' R pointing at stem width while the regularity scale
it should have named was filed under pattern — a letter CEDARS does not have. The collision was
real enough to leak: sanity-font-manager carried if (key === 'rhythm') return 'Width' to
correct the label at the display layer. v6 fixes the names instead of the labels.
Two assignments in the table are readings rather than quotations, and are the first things to
revisit if ILT's own grouping differs — both are flagged as such in src/types.ts:
fillunder Details. It is ILT's owncedarsFill, so it belongs to one of the six; Details is the closest of them.apertureunder Structure. ILT's Structure covers "open vs closed", which is exactly what aperture reads.
One open question: the source comment in src/metrics/rhythm.ts records that ILT's own field name
for this scale is cedarsPattern. CEDARS has no P, so that is presumably an internal name for R —
but it is worth confirming, because if ILT really does call regularity "Pattern" then the letter
map above is still right and only the key name is arguable.
Landmarks, not limits
A CEDARS score is not a percentage and not a percentile. Every scale is defined by two named semantic landmarks — describable reference archetypes — and the map between them is linear (or log-linear) and unclamped in both directions:
| Scale | Raw quantity | Score 0 landmark | Score 100 landmark | Map |
|---|---|---|---|---|
| Contrast | thick/thin ratio r | monoline floor, r = 1.0 | Didone archetype, r = 6.0 | 100·ln(r)/ln 6 |
| Energy | secantDeg + 80·(springHeight/xHeight) | quarter-round shoulder, 45° + 0.5·xH = 85 | broad-nib branch, 80° + 0.85·xH = 148 | (raw − 85)·100/63 |
| Width | width ratio w | centre, text-normal n, w = 2/3 | ±100 at ×1.5 / ÷1.5 (w = 1.0 / 4/9) | 100·ln(w/(2/3))/ln 1.5 |
| Rhythm | coefficient of variation cv | deviation equals budget, cv = 1 | mechanical evenness, cv = 0 | 100·(1 − cv) |
| Aperture | openness ratio a | sealed, a = 0 (physical floor) | full-counter, a = 1.0 | 100·a |
Three consequences worth internalising:
- A landmark is a reference point, never a maximum. Scoring past 0 or 100 is a correct
reading, not an error. Do not clamp, range-validate, or store these as
0..100. - New fonts can never move existing scores. v4's anchors were corpus percentiles (p5/p95 of a 110-font sample), so every re-sweep silently re-scored the entire corpus and no two vintages were comparable. Landmarks are defined without reference to any sample.
- Width is signed.
0means normal proportion, negative is condensed, positive extended — a real zero, which the v4 scale (0 = "the narrowest thing in our corpus") never had.
Labels follow the same logic. Contrast and Width are raw-keyed (thresholds on the measured
quantity, so they survive a landmark re-tune); Energy, Rhythm and Aperture are score-banded,
with the overflow regions named rather than folded into the end band — inert / explosive
for Energy, arrhythmic for Rhythm, wide open for Aperture, and the symmetric
very condensed / condensed / normal / extended / very extended for signed Width.
Impossible situations, honestly scored
The scales that used to saturate now say what they measured. Real Fontsource faces:
| Face | Score | What v4 reported |
|---|---|---|
| Playfair Display 900 | Contrast 113 (r = 7.5) | 100 — tied with every other high-contrast face |
| Hairline Didones (r = 12–25) | Contrast 139–180 | 100 |
| Oswald 400 | Width −129 (w = 0.39) | 0 — "as condensed as anything can be" |
| Ultra-condensed (w = 0.38) | Width −139 | 0 |
| Courier Prime | Aperture 107 (a = 1.07) | ~82, and its rhythm is correctly n/a |
| Libre Bodoni 400 | Energy −1 — gentler than a quarter-round shoulder | 0 |
| Sealed c | Aperture exactly 0 — the one genuine floor | 0 |
Naming: one label set, from one place
Through v7 the package produced labels twice: each metric computed its own inline ternary, and the naming layer banded the raw quantities separately. Two vocabularies for one profile, free to drift — and three of the metric versions banded on a score rather than on a measurement.
Since v8 there is one source. profile.labels comes from REFERENCE_BANDS via labelFor, so the
cut points are exported, stated in their own units and versioned (REFERENCE_BANDS_VERSION). The
collapse was verified label-for-label against v7 across 2,049 Google Fonts families: identical on
every measurable scale, no label changed anywhere. One behaviour did change, intentionally — an
unmeasurable scale now has "" rather than a word, because the bottom band would assert a reading
that was never taken.
describe(profile) returns the same names with their provenance attached:
import { describe, REFERENCE_BANDS, REFERENCE_CORPUS, nameFor, familyReading } from "@overpunch/cedars-engine";
describe(profile).contrast; // { name: "medium", value: 2.1, quantity: "contrastRatio",
// unit: "thick:thin ratio", confidence: "anchored" }Use profile.labels when you just need the word. Use describe when a reader may need to disagree
with where a boundary sits, when you are storing a name and want it traceable to the cuts that
produced it, or when you want to tune the boundaries for your own catalogue — labelFor takes an
overriding band set, and the labels move with it.
What the cuts are, and what they are not
| Scale | Cuts on | v3 cuts | Derived from |
|---|---|---|---|
| Contrast | contrastRatio | 1.15 / 1.6 / 2.5 / 4 | typographic ratios |
| Energy | energyRaw = secantDeg + 80 x springHeight/xHeight | 85 / 100.75 / 116.5 / 132.25 / 148 | the two archetypes, quartered |
| Width | widthRatio | 0.444 / 0.544 / 0.817 / 1.0 | 2/3 landmark, stepped by √1.5 then 1.5 |
| Rhythm | rhythmCv | 0.34 / 0.67 / 1 | cv = 0 and cv = 1 landmarks, thirded |
| Aperture | opennessRatio | 0.33 / 0.66 / 1.0 | sealed = 0 and full counter = 1, thirded |
Every cut is landmark-derived, and deliberately not a percentile. v2 fitted them on corpus
quantiles and that was wrong for labels: Google Fonts text faces cluster so tightly on width that
the middle three-fifths spans only 0.606–0.703, so a quintile cut called a face 1.6% off the 2/3
landmark "extended". That is a statement about the sample, not about the face — and letting the
sample move the names is exactly what v5 ended for scores. REFERENCE_CORPUS.setsReferenceCuts is
false to record this.
The outermost bands are the overflow names the metrics always had: inert/explosive past the two
energy archetypes, arrhythmic past cv = 1 where the spacing deviation exceeds the budget it
varies around, wide open past a full counter, and very condensed/very extended past 1.5x
either side of normal proportion.
rhythmCv runs opposite to the score: RHYTHM.cvAtZero is 1 and cvAtHundred is 0, so a low
cv is an even face and the band names read even → arrhythmic as cv rises.
The corpus, and what it is for
REFERENCE_CORPUS records a measured corpus — one upright per family across 2,049 Google Fonts
families, 1,116 of them text faces (Sans Serif, Serif, Monospace) — and states that it does not
set the reference cuts. It is there for the relative path: corpusBands() and positionIn(),
which answer "where does this sit in my library" and are explicitly not comparable across
libraries. corpusBands() passes every reference scale through untouched; re-fitting is opt-in via
refitAnchored, and a corpus-fitted set is marked corpus-fitted precisely so it cannot be mistaken
for an absolute reading.
Two things the corpus did establish. The text faces' median widthRatio is 0.661, within 1% of
the independently chosen v5 landmark for a text-normal n (2/3) — the archetype was right. And
measurability varies sharply by kind: the energy arch reads on 92% of text faces against 52% of
display and script, which is why a display-heavy corpus cannot define "normal" even if you wanted it
to.
Family rollups carry their own agreement
familyReading(styles) gives one name per scale across a family, with the share of styles that
agreed. The number is not decoration — measured across one foundry's 452 styles, styles within a
family agreed on rhythm 80% of the time and on aperture only 53%, a coin flip. A family-level name
with no agreement beside it asserts more than its styles support, so callers get the figure and can
decline to render a weak one.
Raws: why a recalibration is now cheap
profile.raw persists the raw measured quantities behind the scores. A score is
(raw quantity) × (landmark map); storing only the score fuses the two, so any future
recalibration means re-parsing every font file — the cost that keeps calibration from ever
happening. With raws stored, moving a landmark is a pure re-map: read raw → apply new map →
write score.
profile.raw.contrast; // { ratio }
profile.raw.energy; // { secantDeg, springHeightRatio }
profile.raw.width; // { widthRatio }
profile.raw.rhythm; // { pooledSd, meanBudget }
profile.raw.aperture; // { opennessRatio }
profile.raw.axis; // { angleDeg, stressConfidence } — always presentTwo rules, both enforced by tests:
- Store components, not composites, wherever the composite bakes in a tunable constant.
Energy's raw folds in
springHeightWeightand Rhythm'scvfolds inmeanFloor, so those two scales store their pieces — otherwise retuning either constant would demand a reseed. - A scale's key is present if and only if that scale is available. An unavailable metric's
diagnostics hold placeholders, and persisting a placeholder as a "raw measurement" is exactly
the lie this structure exists to prevent. (
axisis always present; itsangleDeg: nullfor a monoline face is the measurement.)
Nothing here is newly measured — every value was already in profile.diagnostics. This is
persistence, not measurement.
The two-version discipline
The package carries two versions because a profile is the product of two separable things:
| | Means | A change requires |
|---|---|---|
| ENGINE_VERSION | the measurement code — what geometry comes out of the outlines, i.e. the values in profile.raw | a full reseed: re-parse every font file |
| TAXONOMY_VERSION | the landmark map — how raws become scores: landmark constants, scale vocabulary, vector layout | a pure re-map from stored raws; no font files touched |
Bump the one that actually changed. Bumping ENGINE_VERSION for a landmark tweak forces a reseed
nobody needed; failing to bump TAXONOMY_VERSION for one leaves incomparable scores in the same
column.
v6 bumps TAXONOMY_VERSION to "6" and leaves ENGINE_VERSION at "5.0.0". No geometry
changed, so nothing needs re-parsing; no landmark moved, so nothing needs re-mapping either. What
changed is the scale vocabulary, which this stamp explicitly covers, and the migration is the
cheapest of the three — a key rename in stored rows:
| v5 key | v6 key | Values |
|---|---|---|
| pattern | rhythm | identical |
| rhythm | width | identical |
It still earns the bump, for the reason the stamp exists: a consumer reading scores.rhythm across
the boundary gets stem width from a v5 row and spacing regularity from a v6 row, with no error and
no clue that anything moved. Rename the keys in any stored corpus once; the recall vector itself
is untouched (see below), so vectors do not need rebuilding.
ENGINE_VERSION and package.json's version are deliberately not in lockstep when a release
changes only packaging or naming: v5.0.1 and v6.0.0 both report ENGINE_VERSION "5.0.0" because
they measure identically.
Similarity with unbounded scores
Unbounded scores need a bounded similarity coordinate, so each score passes through one shared
squash g(u) over u = (score − lo)/(hi − lo) (windows: [0,100], or [−100,100] for signed
Width):
g(u) = u 0 ≤ u ≤ 1 identity — in-window values untouched
g(u) = 1 + 0.25·tanh(4·(u − 1)) u > 1
g(u) = −0.25·tanh(−4·u) u < 0It is value- and slope-continuous at both edges, strictly monotone, and bounded to
(−0.25, 1.25) — so one overflowing dimension can't swamp a composite, while 167 and 250 remain
different coordinates. Inside the window it is bit-identical to v4's score/100, so all
existing in-window similarity behaviour is preserved exactly.
Use the exported transform — never re-derive a /100, which is no longer correct:
import { scoreToRecallDim, scoreToGowerValue, SCALE_WINDOWS } from "@overpunch/cedars-engine";profile.vector and profileToGower both call it; there is no inline /100 left in the engine.
Unavailable scales still impute 0.5 — and for signed Width that imputation is score 0,
normal proportion, which is the correct agnostic prior. gowerDistance is unchanged (its
per-dim distance is |g(a) − g(b)|, with g pre-applied), but its documented range is corrected:
[0, 1.5], exceeding 1 only when overflowing scores are involved.
v6 does not move a vector dimension. profile.vector is built as CEDARS_SCALES.map(…), so
dimension order follows that array — and the rename kept every slot bound to the measurement it
already held. Slot 2 is still signed stem width (now named width) and slot 3 is still spacing
regularity (now named rhythm). Had the two swapped positions instead, every stored vector would
have remained numerically identical while silently meaning something else.
Measured accuracy
The v4 test for every scale was the same: different weights of the same design should score the same. Measured cross-weight spread within families (roman pool), v3 → v4:
| Scale | v3 spread | v4 spread | |-------|----------:|----------:| | Energy | 69.9 | 16.4 | | Width | 34.1 | 5.8 | | Rhythm | 60.6 | 8.8 | | Aperture | 68.7 | 15.7 |
The recovered stress angle is accurate to ±1.1° (closed-form harmonic fit; the old estimator was an argmin over a 2° grid).
Availability contract. An unavailable scale's stored score is a placeholder 0 — not a
low reading — and an unavailable facet's dist is a uniform placeholder, so unavailable dimensions
stay out of similarity honestly: profileToGower omits them entirely (gowerDistance
renormalizes over the dims present, as it already does for the null monoline angle), and the ANN
recall vector imputes the neutral 0.5 so recall doesn't drag unmeasurable fonts to an
extreme. Since v5, profile.raw omits an unavailable scale's key entirely, for the same reason.
Usage
import { analyzeFont, ENGINE_VERSION, CEDARS_SCALES, CEDARS_FACETS } from "@overpunch/cedars-engine";
// buffer: ArrayBuffer / Uint8Array (browser File) or a Node Buffer.
const profile = analyzeFont(buffer);
profile.scores; // { contrast, energy, width, rhythm, aperture } — UNBOUNDED landmark
// scores, rounded. NOT 0..100: 113 and −129 are real readings. Never clamp.
profile.raw; // the raw measured quantities behind those scores — persist these
profile.labels; // human-readable per scale, e.g. { contrast: "extreme", width: "very condensed" }
profile.availability; // per scale: false = not measurable (0 is a placeholder, not a low reading)
profile.axis; // { hasStress, angleDeg (0..180 | null), label } — the stress ANGLE
profile.facets; // per facet: { top, confidence, dist } — soft categorical classification
profile.vector; // RECALL vector: [scoreToRecallDim(scale, score) …, sin2θ·conf, cos2θ·conf]
profile.plus; // { xHeightRatio, capHeightRatio, isMonospaced }
profile.diagnostics; // typed raw measurements per scale + angle
profile.facetDiagnostics;// typed raw features behind each facet classifieranalyzeFont throws a FontParseError for unsupported/corrupt input — WOFF2 is not supported
(opentype.js 1.x has no Brotli decoder); convert to OTF/TTF/WOFF first. Wrap the call in try/catch.
Also exported: parseFont(buffer) → FontContext, analyzeProfile(ctx), profileToGower(profile)
(omits unavailable dims — see the availability contract above), the score→similarity transform
scoreToRecallDim / scoreToGowerValue / squash / SCALE_WINDOWS /
UNAVAILABLE_IMPUTATION / isCedarsScale, similarity helpers euclidean /
cosineDistance / nearest and the mixed-type gowerDistance / rerankByGower /
DEFAULT_GOWER_WEIGHTS, the vocabularies CEDARS_SCALES / CEDARS_FACETS / FACET_CLASSES /
CEDARS_LETTERS / CEDARS_PLUS_SCALES, ENGINE_VERSION + TAXONOMY_VERSION (stamp
corpus rows to detect drift), the legacy CEDARS_AXES / CEDARS_DIMENSIONS (for reading pre-3.0
rows), and all types.
What each axis measures
The Measurement column is what geometry is extracted (ENGINE_VERSION); how it becomes a
score is the landmark table above (TAXONOMY_VERSION).
| Axis | Kind | Glyph | Measurement |
|------|------|-------|------------------|
| Contrast | scale | o | p95/p5 of perpendicular-corrected stroke thickness around the de-slanted bowl ring |
| Energy | scale | n (h m agreement checks) | springing suddenness + springing height of the stem→arch transition — how high and how suddenly the arch darts off the stem |
| Width | scale | n | de-slanted stem-centerline distance ÷ x-height (proportion, not advance — spacing is Rhythm). Signed: 0 = normal proportion |
| Rhythm | scale | many | within-structural-class evenness of the per-glyph sidebearing budget; monospace → n/a |
| Aperture | scale | c e | terminal-endpoint distance ÷ counter height (open humanist ↔ closed grotesque; closed = 0) |
| Axis | angle | o | closed-form second-harmonic fit of the de-slanted ring thickness series, with graded stressConfidence; monoline ⇒ hasStress:false |
| Loops | facet | o | 7-shape classifier over [squareness n, contrast-corrected aspect ratio, centroid taper] of the counter |
| Terminals | facet | l (c) | stem-top width-profile classifier → blunt/angled/flared/rounded/tapered/ball; a radial curved-terminal probe on c catches ball terminals when the stem carries a serif |
| Transitions | facet | n | stem↔arch junction span ÷ x-height (stem-relative leaked weight) + a real disconnectivity test → blended/tapered/abrupt/disconnected |
| Fill | facet | o | ink fraction across the left band (outer edge → counter edge) + counter topology → solid/hollow/inline/texture/ornament — a carved inline channel is actually detectable now |
| Construction | facet | a g | counter topology → double-story / single-story / mixed: a double-story a with a single-story g (most mainstream sans) lands decisively on mixed instead of the old deterministic 47/47 tie |
The bowl is de-slanted by the font's italic angle before measuring Contrast, Axis, and Loops, so
instances of the same design don't drift across upright/italic, and the class/self-ratio metrics
stay stable across weights. (The declared italic angle is also verified geometrically against the
measured stem slant — diagnostics.axis.slantUsedDeg records what was actually removed.)
Axis diagnostics: named angles, one deprecation trap
AxisDiagnostics exposes the stress geometry under honest names: axialAngleDeg /
thinDiameterAngleDeg (axial, [0,180), 90 = vertical), thinDeviationFromVerticalDeg /
thickDiameterAngleDeg (signed, (−90,90]), their original-frame variants
thinDiameterAngleOriginalDeg / thickDiameterAngleOriginalDeg (mapped back into the slanted
glyph frame, for drawing overlays), and stressConfidence (0..1, scales the sin2θ/cos2θ
embedding continuously toward the monoline point).
Trap: the legacy stressAngleDeg field is deprecated — it is the signed deviation of the
thin diameter from vertical and differs from axis.angleDeg by 90°. Use
thinDeviationFromVerticalDeg (same value, honest name) or the diameter fields. And never read
any angle field without checking hasStress / stressConfidence first: when monoline, every
angle holds a placeholder.
Calibration & roadmap
Calibration lives in src/constants.ts (landmarks, categorical prototypes). Every landmark is a
named archetype stated in its comment, and the v4 "anchor sweep" roadmap item is deleted, not
deferred: re-deriving anchors from corpus percentiles is exactly the practice v5 exists to end.
What replaces it:
- Landmark review (
TODO(landmark-review)in the source) — argue about whether the named archetype is the right archetype (is a broad-nib branch really 80° at 0.85·xH?), which is a typographic question with a checkable answer, unlike "what did our sample happen to contain". Any outcome is a re-map from stored raws, not a reseed. - Human-ratings calibration — re-ground the landmarks on human consensus via isotonic
regression. A calibration game at
cedarstype.com/calibratefeeds it; that site is not public yet (it currently returns 401), so treat the link as forthcoming rather than live. Note that human ratings are collected on a bounded slider while scores are unbounded, so the fit must be to the squashed coordinate, not the raw score. - Transitions weight stability — x-height normalization removed the first-order weight leak (Light read "blended" while Black read "abrupt"); verify the residual across full weight spans.
- Letter-map review — confirm
fillunder Details andapertureunder Structure against ILT's own grouping, and settle whether theircedarsPatternis an internal name for R. - Known Energy limitation: "suddenness" saturates on square-cornered constructions, so squared-shoulder display designs (Eurostile-likes) read energetic; re-examine at the landmark review.
Development
npm run build # tsup → dist (ESM + CJS + types + sourcemaps)
npm test # vitest — 146 tests across 11 files: geometry, similarity, profile invariants,
# per-metric accuracy regressions, and the landmark/squash/raws contracts
npm run typechecktest/landmark.test.ts is the guard rail for everything above: it pins each map at its landmarks,
pins the overflow and monotonicity beyond them, pins the squash's slope continuity and its
bit-identical in-window behaviour, and round-trips profile.raw back to profile.scores —
that round-trip is the contract that makes a future re-map safe. If you change a landmark, expect
it to fail, and update it with a recomputed expectation rather than a loosened one.
Bump ENGINE_VERSION when the measurements change (consumers must re-seed) and TAXONOMY_VERSION
when the landmark map or the scale vocabulary changes (consumers re-map, or rename keys) — see the
two-version discipline above.
License
MIT © Overpunch
CEDARS™ is a trademark of I Love Typography Ltd, used with permission. This package is an independent implementation and is not affiliated with or endorsed by I Love Typography.
