Turn learning artifacts into competency evidence — pre→post learning gain, class gaps, and mastery rollups.
You have the raw traces of learning: test attempts, mastery sessions, chat transcripts. Sextant turns them into the answers instructors actually ask — Did they learn? Where's the class weak? Who's mastered what? The analytics are pure and deterministic (no model, no network, no key); the optional competency read of a conversation uses any OpenAI-compatible model, with a deterministic heuristic fallback so it always returns something useful.
npm install sextantRequires Node 18+. ES modules only.
import { learningGain, classGaps, masteryRollup, summarize } from 'sextant';
const attempts = [
{ objective: 'Photosynthesis', phase: 'pre', correct: false },
{ objective: 'Photosynthesis', phase: 'post', correct: true },
{ objective: 'Cell Respiration', phase: 'pre', correct: false },
{ objective: 'Cell Respiration', phase: 'post', correct: false },
];
learningGain(attempts);
// {
// objectives: [
// { objective: 'Cell Respiration', prePct: 0, postPct: 0, gain: 0, normalizedGain: 0, preN: 1, postN: 1 },
// { objective: 'Photosynthesis', prePct: 0, postPct: 1, gain: 1, normalizedGain: 1, preN: 1, postN: 1 },
// ],
// overall: { prePct: 0, postPct: 0.5, gain: 0.5, normalizedGain: 0.5 }
// }
classGaps(attempts, { minCohort: 1 }); // tiny demo cohort; default minCohort is 5 (see Privacy)
// [ { objective: 'Cell Respiration', attempts: 2, cohort: 2, missRate: 1 },
// { objective: 'Photosynthesis', attempts: 2, cohort: 2, missRate: 0.5 } ] // worst-firstgain is the raw correctness delta (post − pre). normalizedGain is Hake's g =
(post − pre) / (1 − pre) — the fraction of the available headroom a cohort closed, which makes
objectives with different starting points comparable. It is null when pre-correctness is already at
the ceiling (100%), where the metric is undefined.
An objective (or the overall roll-up) that was assessed in only one phase — no pre attempts, or no
post attempts — has no measurable delta. Its missing prePct/postPct and both gain and
normalizedGain come back as null rather than a phantom 0, and such objectives sort last.
correct counts only when it is literally true (a truthy string like 'no' is a miss, not a pass).
import { masteryRollup } from 'sextant';
masteryRollup([
{ competency: 'Photosynthesis', verdict: 'mastered' },
{ competency: 'Photosynthesis', verdict: 'developing' },
{ criteria: [{ competency: 'Osmosis', verdict: 'mastered' }] }, // session-style record
]);
// [ { competency: 'Photosynthesis', developing: 1, competent: 0, mastered: 1, n: 2, masteredRate: 0.5 },
// { competency: 'Osmosis', developing: 0, competent: 0, mastered: 1, n: 1, masteredRate: 1 } ]import { summarize } from 'sextant';
summarize({ attempts, sessions });
// { gain: { ... }, gaps: [ ... ], mastery: [ ... ] }classGaps groups by objective and ranks — its return shape is one row per objective, never per
learner, and there is no code path that returns an individual's performance.
That alone is not enough: a row backed by a single learner still is that learner. So classGaps
also suppresses small cells. Each row carries a cohort — the number of distinct
learnerIds that produced it (or, when no attempt carries a learnerId, the attempt count as a
conservative proxy) — and any row whose cohort is below minCohort (default 5) is dropped
entirely rather than returned. A one-learner objective is never emitted.
const attempts = [
{ objective: 'Ratios', correct: false, learnerId: 'a' }, // only one learner on Ratios…
{ objective: 'Ratios', correct: true, learnerId: 'a' }, // …still a cohort of 1
// …plus five distinct learners on 'Fractions'…
];
classGaps(attempts); // 'Ratios' is suppressed (cohort 1 < 5)
classGaps(attempts, { minCohort: 1 }); // opt into showing thin cells (e.g. a single classroom)learnerId is read only to size the cohort and is never surfaced in the output.
Classify a learner's turns against Bloom's taxonomy (remember → understand → apply → analyze → evaluate → create) and surface skill indicators.
import { competencyEvidence } from 'sextant';
// Deterministic keyword-verb heuristic — no API key, no network:
await competencyEvidence(transcript, { heuristicOnly: true });
// { tiers: { remember: 1, understand: 1, apply: 0, analyze: 0, evaluate: 0, create: 1 },
// highestTier: 'create', depthScore: 6, indicators: [], method: 'heuristic' }
// With a model (set SEXTANT_API_KEY) for a richer read + skill indicators:
await competencyEvidence(transcript);
// { ..., indicators: ['designed a controlled comparison', ...], method: 'model' }The model path is injectable — pass fetch, apiKey, endpoint, and/or model in opts to
point at a specific backend or to test it offline with a stub; each falls back to the module default
or environment variable when omitted. The model's reply is validated before use: tier names are
lowercased and checked against the taxonomy (so a stray "Analyze" still scores), unknown tiers are
dropped, and any malformed or unusable reply falls back to the heuristic.
await competencyEvidence(transcript, { fetch: myFetch, apiKey: key, model: 'my/model' });The transcript may be a plain string (one turn) or an array of strings and/or { role, text }
objects; turns whose role is assistant or instructor are dropped so only the learner is scored.
method reports which path produced the result: 'model', 'heuristic', or 'empty'. When a model
is configured but the call fails or returns no usable JSON, it falls back to the heuristic.
| Artifact | Shape | Notes |
|---|---|---|
| attempt | { objective, correct, phase?, learnerId? } |
One scored answer. objective may instead be topic, section, or competency (first present wins; else 'overall'). phase is 'pre' or 'post' — required for learningGain, optional for classGaps. correct counts only when it is literally true. learnerId sizes the cohort for small-cell suppression and is never surfaced. |
| session (mastery record) | { competency, verdict } or { criteria: [{ competency | elo, verdict }] } |
verdict is one of 'developing', 'competent', 'mastered' (others count toward n but no bucket). |
| transcript | string or ({ role, text } | string)[] |
Learner turns are scored; assistant/instructor turns are dropped. |
| Export | Signature | Returns |
|---|---|---|
learningGain |
(attempts) => { objectives, overall } |
Per-objective and overall pre→post gain and normalizedGain (Hake's g). A phase absent → the affected fields are null, not 0. Objectives sorted by raw gain, worst first (null gains last). |
classGaps |
(attempts, { phase?, minCohort? }) => [{ objective, attempts, cohort, missRate }] |
Miss rate per objective, worst-first. Aggregate by construction and small-cell-suppressed: rows with cohort < minCohort (default 5) are dropped. phase restricts to one phase. |
masteryRollup |
(records) => [{ competency, developing, competent, mastered, n, masteredRate }] |
Verdict distribution + mastered-rate per competency, worst-first. |
summarize |
({ attempts?, sessions? }) => { gain, gaps, mastery } |
Runs the three analytics over a mixed artifact set; empty inputs return null/[]. |
competencyEvidence |
async (transcript, { heuristicOnly?, fetch?, apiKey?, endpoint?, model? }) => { tiers, highestTier, depthScore, indicators, method } |
Bloom's-tier profile + skill indicators for a conversation. Model path is injectable/mockable; model replies are validated. |
All functions validate their inputs and throw a TypeError with a clear message on bad shapes.
Subpath imports are available: sextant/analytics and sextant/competency.
Only competencyEvidence (without heuristicOnly) reads these; the analytics never do.
| Variable | Default | Purpose |
|---|---|---|
SEXTANT_API_KEY |
— | API key for the chat model. OPENROUTER_API_KEY is also accepted. Without a key, the call falls back to the heuristic. |
SEXTANT_ENDPOINT |
https://openrouter.ai/api/v1/chat/completions |
OpenAI-compatible chat-completions endpoint. |
SEXTANT_MODEL |
google/gemini-3-flash-preview |
Model id to request. |
node example/demo.mjs # runs fully offline (analytics + heuristic competency read)
npm test # the analytics + heuristic path are fully unit-tested, no keyApache-2.0. See LICENSE.