Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🧭 Sextant

CI License: Apache-2.0

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.

Install

npm install sextant

Requires Node 18+. ES modules only.

Quick start

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-first

gain 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).

Mastery rollups

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 } ]

One-call summary

import { summarize } from 'sextant';

summarize({ attempts, sessions });
// { gain: { ... }, gaps: [ ... ], mastery: [ ... ] }

Privacy by construction

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.

Competency evidence from a conversation

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 shapes

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.

API

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.

Environment variables

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.

Try it

node example/demo.mjs   # runs fully offline (analytics + heuristic competency read)
npm test                # the analytics + heuristic path are fully unit-tested, no key

License

Apache-2.0. See LICENSE.

Releases

Packages

Contributors

Languages