npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

asenion

v0.3.0

Published

Validate, check and match LLM responses, and route the messages sent to them

Readme

const { check } = require('asenion')

const reply = 'Sorry — the vault opens at nine.'
const verdict = check(reply, { has: ['nine'], excludes: ['sorry'], oneLine: true })

if (!verdict.ok) console.error(verdict.failures)
// [{ rule: 'excludes', term: 'sorry', detail: 'the words are present', band: 'deterministic' }]

Model output is sampled, so it is never byte-identical twice, and it arrives wrapped in whatever the model felt like adding — a Title: label, smart quotes, a decorative Unicode form, a preamble. This folds that away and answers one question: does the reply obey the laws it was given?

Why

Five things go wrong with model output, and one with what goes in. All six are this module's job.

A test asserts what the model said, and passes when it shouldn't. A sampled answer is never the same twice, so a test compares loosely — and a loose comparison is a substring test, which is how libro passes on librotecario.

has('el librotecario', 'libro') // false — words, not substrings

You are about to act on a value the model produced. An address it wants to email, a URL it wants to fetch. Did that come from anywhere you trust — and if it did not, is it merely absent, or is it drawn to look like one that is?

grounded('а[email protected]', ['[email protected]']).failures[0].looksLike
// '[email protected]' — the Cyrillic а makes it a different address

A reply is on its way to a screen with the chat template still in it. A control token, a leaked tool call, the system scaffold as prose. It has to be caught as it streams, before it is shown.

check('The vault opens at nine.<|im_end|>', { noneOf: markers.controlTokens }).failures
// [{ rule: 'noneOf', term: '<|im_end|>', detail: 'at character 24', band: 'structural' }]

A value you asked for plain comes back quoted, and every failed retry quotes it again.

unwrap('"\\"Ledger\\""') // 'Ledger'

A prompt works, and you do not know whether it works reliably. One good answer is one sample.

const samples = ['the vault opens at nine', 'The vault opens at nine.', 'nine, on weekdays']

agree(samples, { min: 0.6 }).failures.map((f) => f.samples) // [[0, 2], [1, 2]]

A user asks for something the words already name, and a model call is what every message costs. "Make me a doc called Q3 Plan" says which intent it wants and what to call the result.

const classify = intents({
  'create-document': { examples: ['make me a document'], patterns: ['called {name}'] }
})

classify('make me a doc called Q3 Plan')[0].slots // { name: 'Q3 Plan' }

Everything is pure, synchronous and deterministic — no I/O, no clock, no observable state, and two per-character memo caches bounded by the code points a process actually folds. check returns a verdict rather than throwing, so a test turns it into an assertion and application code turns the same verdict into a retry, a fallback, or a refusal to act on an answer that did not arrive intact.

The name

In Asimov, an Asenion robot is one built in provable conformance to the Three Laws — not one trusted to behave, one whose conformance can be stated and checked. That is the question this module asks of a model's output.

Every law it carries today is lexical and deterministic: words folded, split, tagged and compared, with no corpus, no embeddings and no notion of what a sentence is about. That is the floor, not the ceiling — see Roadmap.

Installation

npm i asenion

API

fold(text[, options])

The comparable form of text: diacritics, styled and fullwidth characters and typographic quotes folded to their plain counterparts, case lowered, whitespace collapsed.

fold('𝐄𝐥 Libro está SOBRE la mesa.') // 'el libro esta sobre la mesa'
fold('еl libro') // 'еl libro' — the Cyrillic е is left standing
  • preserveLength — return a string of exactly the same length as text, by leaving alone every character whose plain form is a different length. An offset into the result addresses the same character in the source, which is what makes it safe to find a match in the fold and slice the snippet out of the original.
fold('one (two)') // 'one two' — a parenthesis is decoration
fold('one (two)', { preserveLength: true }) // 'one (two)'

A homoglyph is never folded. A Cyrillic е stays a Cyrillic е, so a lookalike fails a comparison loudly instead of passing as the Latin word it is drawn like. A symbol is not read as the letter it is drawn like either — $5 stays $5 rather than becoming s5.

ascii(text)

The ASCII a text is written in, with its case left alone — for an engine that speaks only ASCII, or a channel that carries only ASCII.

ascii('Straße — file') // 'Strasse - file'
ascii('£5 and 日本') // '£5 and 日本' — a symbol is not a letter, and neither is mistranslated

Not a fold: comparing two texts means folding their case, and reading one aloud does not. Only characters that are not already ASCII are touched, so (a) keeps its parentheses where fold reads them as decoration. What has no ASCII form is left in place for the caller to report; a homoglyph is left too, because reading a spoofed word aloud as the word it imitates is the same silent pass the fold refuses.

homoglyphs(text)

The lookalike characters in text, each with the character it imitates and the writing system it came from. Empty when the text is plain.

homoglyphs('the pаypal invoice')
// [{ char: 'а', position: 5, looksLike: 'a', writingSystem: 'cyrillic' }]

normalize(text)

fold, plus contractions expanded and punctuation dropped — the canonical form two texts are compared in.

normalize("Hi how're you?") // 'hi how are you'
normalize('I cannot help') // 'i can not help' — as "I can't help" also normalizes
normalize('dont worry') // 'do not worry' — typed without the apostrophe

tokens(text)

The words of text, normalized: runs of letters and numbers, in order.

tokens('The dog says hi!') // ['the', 'dog', 'says', 'hi']

tag(text[, options])

The words of text, each with a part of speech: noun verb adj adv det pron prep conj aux num interj. tag is the likeliest reading and tags every reading the text leaves open, likeliest first.

  • verbs — the words the caller's commands open on. The lexicon carries the language's closed classes and its commonest verbs; which words a product takes commands in is the product's to say. A caller's verb is a noun as often, so it keeps both readings: email ada opens on a verb, check the email ends on a noun. A verb with a particle names its verb: sign in makes sign one and leaves in a preposition.
tag('The dog says hi')
// [{ word: 'the', tag: 'det', tags: ['det'] }, { word: 'dog', tag: 'noun', tags: ['noun'] },
//  { word: 'says', tag: 'verb', tags: ['verb'] }, { word: 'hi', tag: 'interj', tags: ['interj'] }]

A word has two readings where its spelling argued for one and the words around it for another. The tagger does not know what Sunday means, only where it stands, so it says both rather than guess:

tag('Sunday the vault opens')[0] // { word: 'sunday', tag: 'verb', tags: ['verb', 'noun'] }

A command opens on its verb, so a word that opens a clause and takes an object reads as one, whatever it is spelled like: draft a doc, please open my drive, clean up the docs, log out. A clause opens at the start of a sentence, after a comma, a colon or a dash (- too), or after a conjunction, an interjection or an adverb — and then send it, so quickly draft it — and a negation leaves it open, so never open the door opens on its verb. An object never follows its verb across a comma: Paris, my favourite city is not a command. An adverb is the exception — finally the vault opens — and so is a word followed by a subject or a relative, which opens a clause of its own: people who draft reports.

Where a command and a statement are spelled alike, the command is the second reading rather than the first: check it works and Mike this is it, draft the doc and Bob the builder is here, make a doc and draft my report and cats and dogs my sister owns.

A word the lexicon lists under another tag, like open, is a verb only where a command opens on it or something acts through it: open my drive, can you open it, but not the open door or the file is open. A verb in its base form is a noun after an article, or an article and an adjective the lexicon knows: the send button, on the go, a new take.

A word the lexicon does not know is a noun unless the words around it argue otherwise, and a spelling with two readings keeps both. Neither is a guess this makes for the caller: the vocabulary is the caller's to give, and where a spelling stays open the pattern the caller writes, or the model behind it, is what decides.

tag('Thanks. Draft a doc').map((t) => t.tag) // ['interj', 'verb', 'det', 'noun']

closedClass(word)

Whether word belongs to a class the language does not add words to — a determiner, pronoun, preposition, conjunction or auxiliary. That is what a stop word is, so a caller filtering tokens for the ones worth matching on asks this rather than keeping a list.

closedClass('the') // true
closedClass('data') // false — whatever a hand-kept stop list would have said

tag is the question about a word in a sentence; this is the question about a word on its own.

has(text, term)

Whether term appears in text as a run of whole words. A slug matches its prose spelling.

has('el librotecario', 'libro') // false
has('open my google drive now', 'google-drive') // true

matches(text, pattern[, options])

Whether any run of words in text fits pattern. A pattern is words, #tag placeholders, * for exactly one word of any kind, and {name} slots for one or more words. A pattern that holds a slot fits within one sentence. A #tag fits a word on any of its readings, so a pattern does not fail on the tagger's guess, and a placeholder may carry the punctuation of the sentence it is written in: email {who}, about {topic}, send {who}'s file.

matches('The dog says hi', '#noun says hi') // true
matches('the vault opens at nine', 'the * opens') // true
matches('draft a doc called Q3 plan', '#verb a doc called {name}') // true

capture(text, pattern[, options])

What each {name} slot in pattern holds, or null where the pattern fits nowhere. A value is sliced from text rather than rebuilt from its words, so it comes back as it was written — the case of a title, the dots of an address, and a lookalike character too, for grounded to refuse.

capture('go and make me a doc called Q3 Plan', 'called {name}') // { name: 'Q3 Plan' }
capture('email [email protected] about the launch', 'email {who} about {topic}')
// { who: '[email protected]', topic: 'the launch' }

A slot holds as few words as the rest of the pattern allows, and one that ends the pattern holds the rest of its sentence — not the rest of the text, so a second instruction is not read as part of the first. A sentence ends where tag ends one: at a line break, or at . ! ? ; or … and any closing quote or bracket after it, before a space. A full stop after a title or dotted letters shortens a word instead — Dr. Smith, 5 p.m. tomorrow — and a colon opens a clause, so Note: buy milk fits note {text}. Both take the same verbs.

Within a sentence, a clause that opens on a verb after a comma, a colon, a dash or a conjunction is a second instruction too, and please or thanks at the end is not part of the value — weather in london please is London — while a clause that opens on a noun is: called Plans, Budgets and Goals. A break the pattern writes after its last slot ends it at the first in the text: call it {name},. A slot that opens a pattern starts at its own clause, past the words that lead into it, so ok so yesterday Ada said hi is Ada saying it. What a pattern spells out next to a slot is not the slot's: send {who}'s file names Ada.

capture('email ada about the about page', 'email {who} about {topic}')
// { who: 'ada', topic: 'the about page' }
capture('Make a doc called Q3 plan. Then email it.', 'called {name}') // { name: 'Q3 plan' }
capture('call it Q3 Plan… then email it', 'call it {name}') // { name: 'Q3 Plan' }
capture('call it Bob, then send it to Ada', 'call it {name}') // { name: 'Bob' }
capture('called Plans, Budgets and Goals', 'called {name}') // { name: 'Plans, Budgets and Goals' }
capture('call it Q3 plan and email bob', 'call it {name}') // { name: 'Q3 plan and email bob' }
capture('call it Q3 plan and email bob', 'call it {name}', { verbs: ['email'] }) // { name: 'Q3 plan' }

A verb nobody named opens no clause, which is why the value runs on above until the caller says email is one. Where the words are alike either way — called Draft, Review and Send is a title or a second instruction — the slot stops at the verb, and a caller who names titles like that writes the pattern to say so.

A pattern with no slots that fits captures {}, so capture answers everything matches does.

frame(text[, options])

Whether text opens and closes on the words given as opens and closes, ignoring the punctuation around them.

frame('The answer is 42.', { opens: 'the', closes: '42' }) // true
frame('Sure! The answer is 42.', { opens: 'the' }) // false — a preamble is not the answer

similarity(a, b[, options])

The share of words the two texts hold in common, from 0 to 1.

similarity('the cat sat', 'the cat sat down') // 0.75
  • phrase — how many words a run has to hold to count as held in common. One, the default, says the two texts are about the same things; four says one of them quotes the other. A text too short to hold a single run of that length shares nothing.
similarity('it says the vault opens at nine today', 'the vault opens at nine', { phrase: 4 }) // 0.4
similarity('nine at opens vault the', 'the vault opens at nine', { phrase: 4 }) // 0

check(text, laws)

Runs every law in laws and returns { ok, failures }. Each failure names the rule that failed, the term it was given, why, and the band it came from — so a caller never has to diff two visually identical strings to learn what went wrong.

check('The vault opens at nine.', { has: ['nine'], oneLine: true })
// { ok: true, failures: [] }

check('Sure! Here it is: the vault opens at nine.', { opens: 'the', excludes: ['sure'] })
// { ok: false, failures: [
//   { rule: 'excludes', term: 'sure', detail: 'the words are present', band: 'deterministic' },
//   { rule: 'opens', term: 'the', detail: 'it opens on "sure"', band: 'deterministic' }] }

| Rule | | | ----------- | ------------------------------------------------------------------- | | has | every term appears as a run of whole words | | excludes | no term appears | | matches | every pattern fits some run of words | | noneOf | no marker appears, matched as a literal on the raw text | | opens | the text opens on these words | | closes | the text ends on these words | | oneLine | nothing sits on a line of its own | | maxChars | the folded text is no longer than this | | similarTo | { text, min, phrase } — the texts overlap by at least min | | plain | no homoglyphs. On unless set to false | | verbs | the caller's verbs, for the matches patterns, as tag reads them |

bands

A failure carries the band its law belongs to. deterministic is lexical and pure — the words, and nothing else. structural is pure too, but its subject is not the words: framing the template leaked, or a value the caller extracted. A caller runs the cheap band in a unit test and every band in staging, and reads from the verdict which it got.

grounded(value, trusted)

Whether value is one the caller already trusts — an address the user typed, a URL a tool returned — as a verdict of the same shape check gives.

grounded('[email protected]', ['[email protected]'])
// { ok: true, failures: [] }

grounded('а[email protected]', ['[email protected]'])
// { ok: false, failures: [{ rule: 'grounded', term: 'а[email protected]', band: 'structural',
//   looksLike: '[email protected]',
//   detail: 'it is drawn like "[email protected]" — "а" (cyrillic) looks like "a"' }] }

The match is exact. Case and surrounding ASCII space aside nothing is folded, because a fold that made josé@example.com ground [email protected] would send mail to an address nobody gave. A value padded with something invisible rather than with a space — a byte order mark, a no-break space — is not the value it is drawn like either, and gets the near miss below. The fold is used only to explain a near miss: a value that is not trusted but is drawn like one that is gets a failure naming what it imitates — in looksLike as well as in the prose — instead of one reporting that it appears nowhere, which is the wrong reason and reads to a model as "try again".

quantities(text)

The numbers in text that carry a unit, in order, each with where it sits in the text as given.

quantities('Max 14°C, with 10-20 mm of rain')
// [{ amount: 14, to: undefined, joiner: undefined, unit: '°C', start: 4, end: 8 },
//  { amount: 10, to: 20, joiner: '-', unit: 'mm', start: 15, end: 23 }]

A unit is spelled one way however the text spelled it: 14 °C, 14°C and 14 degrees Celsius are the same °C, and 9 kph is 9 km/h. A range is one quantity — 10-20 mm, 5 to 10 mm, 14°C to 16°C and between 6°C and 12°C — where max 14°C and min 7°C is two, and so is 6°C or 12°C: an alternative is not a range. A range says what joined its ends in joiner: '-', 'to', 'and' after between, or 'from' for from 14°C to 16°C, so a change is not read as a spread. A number with no unit is not a quantity: a year or a head count would otherwise be every claim's second fact. The unit table is short and fixed — temperature, speed, length, mass, volume, time, energy, pressure, percent and a few currencies — and a unit it does not list reads as no unit.

atomic(claim)

Whether a claim states one fact, as a verdict of the same shape check gives. A second quantity, a second sentence, or a clause that a comma hands over with with, while, whereas, although or but is a second claim, and each is a failure naming the words that opened it.

atomic('Max 14°C by day and a min of 7°C at night, with light winds.')
// { ok: false, failures: [
//   { rule: 'atomic', term: '7°C', detail: 'a second quantity in one claim', band: 'deterministic' },
//   { rule: 'atomic', term: 'with', detail: 'opens a second clause', band: 'deterministic' }] }

atomic('Temperatures are between 6°C and 12°C.') // { ok: true, failures: [] }

The same quantity said twice is one fact, a range is one fact, and a list is one fact: Alice, Bob, and Carol met passes. The law refuses a compound claim and does not split it, because splitting is knowing what each part is about, and that is not a lexical question. A caller that holds a model asks it to split, and runs atomic over each part.

stable(samples, laws)

check takes one reply. Whether a prompt is stable is a question about many: sample it n times and ask whether every sample obeys the laws. A failure names the sample it came from, so a flake reads as "sample 2 broke has" rather than as a ratio.

stable(['the vault opens at nine', 'The vault opens at nine.', 'sorry, I cannot help'], {
  has: ['nine']
})
// { ok: false, failures: [{ rule: 'has', term: 'nine', detail: 'no such run of words',
//   band: 'deterministic', sample: 2 }] }

agree(samples[, options])

Whether the samples say the same thing as each other, whatever the laws say about any one of them — a prompt that answers correctly and differently every time is still not one to build on.

agree(['the vault opens at nine', 'the vault opens at nine today'], { min: 0.6 })
// { ok: true, failures: [] }

agree(['the vault opens at nine', 'I have no idea'], { min: 0.6 })
// { ok: false, failures: [{ rule: 'agree', detail: 'they overlap 0.00, under 0.6',
//   band: 'deterministic', samples: [0, 1] }] }
  • min — the share of words every pair has to hold in common. With no floor they have to be the same words.
  • phrase — as on similarity, so a floor can ask for the same runs of words rather than the same words in any order.

unwrap(text)

Strips wrapping quotes and their escapes until the value stops changing. A small model quotes a value it was asked for plain, and every failed retry quotes it again.

unwrap('"\\"Ledger\\""') // 'Ledger'
unwrap('"yes" or "no"') // '"yes" or "no"' — two quoted things are not one quoted thing

asenion/guards

The structural band: what a reply must not carry because it belongs to the chat template rather than to the answer, and what a payload carries that a law has to reach.

const { markers } = require('asenion/guards')

check('all done<|im_end|>', { noneOf: markers.controlTokens }).ok // false

| Set | | | --------------- | ---------------------------------------------------------------------------------- | | turnEnd | <\|im_end\|>, <\|endoftext\|>, <\|eot_id\|>, <\|eom_id\|>, <end_of_turn> | | runaway | <\|im_start\|> — a turn header inside the answer | | controlTokens | both of the above | | scaffold | a leaked tool scaffold, reaching the answer as prose | | toolMarkup | <tool_call>, <function=, <parameter= and their closers | | thinking | <think> / <thinking> and their closers |

Every set is a plain array. A template none of them has seen is an entry in the caller's own array, never a reason to wait on this module.

firstMarker(text, markers[, from])

The earliest marker in text at or after from as { index, marker }, or null. Where two start at the same place, the longer one wins.

firstMarker('all done<|im_end|>ghost', markers.controlTokens)
// { index: 8, marker: '<|im_end|>' }
  • from — where to start looking, so a caller walking a text forward does not rescan what it has already passed.

truncateAt(text, markers) / strip(text, markers)

Everything before the earliest marker, and the markers excised from the words around them.

truncateAt('all done<|im_end|>ghost', markers.controlTokens) // 'all done'
strip('all <|im_end|>done', markers.controlTokens) // 'all done'

Two verbs because they are never the same thing: a leaked scaffold has a body, so stripping its heading leaves the tools JSON behind, while a stray control token has to come out from between the words it interrupted.

strings(payload[, options])

Every string a payload carries, including one hidden inside a nested JSON string. A tool call arrives as an object of unknown shape, and a law cannot ask about values it never reached.

const args = { body: JSON.stringify({ to: '[email protected]', ref: 'r-1' }) }

;[...strings(args)] // ['[email protected]', 'r-1']
;[...strings(args, { skip: (name) => name === 'ref' })] // ['[email protected]']
  • skip — prune a property by name. Which fields carry a value worth checking, and which carry an identifier that only looks like one, is the caller's to say.

decodeBase64(value)

The text a value hides inside base64, or null where it hides none. A model asked not to send something can still send it encoded, and what is hidden in a value is still what the model produced.

decodeBase64('dG86IGFkYUBsb3ZlbGFjZS5kZXY=') // 'to: [email protected]'
decodeBase64('hello') // null — a short word is not an encoding

printableRatio(text)

The share of characters that are text, from 0 to 1. U+FFFD counts against it and never for it: it is what invalid bytes decode to, which is proof they were never text.

printableRatio('to: [email protected]') // 1
printableRatio(decodeBase64('/9j/4AAQSkZJRgABAQAAAQABAAD')) // 0.2 — jpeg bytes were never text

Decoding cannot fail, so this is how a caller tells an encoded sentence from an image, an id or a digest. The threshold is the caller's.

asenion/scan

The same laws, held against text that arrives a piece at a time. A UI shows a token the moment it arrives, so framing has to be caught before the answer exists rather than checked once it does.

Nothing here is a stream: no events, no backpressure, no destroy. It is a scanner the caller drives, because the caller owns the transport — an async generator that pulls, a stream it writes into, a socket. Wrapping push and flush in whatever that transport is costs a few lines and belongs where the transport already lives.

const { scanner } = require('asenion/scan')
const { markers } = require('asenion/guards')

const safe = scanner({ stop: markers.turnEnd, suppress: [['<think>', '</think>']] })

let shown = ''
for (const piece of ['The vault ', '<think>which one</think>opens ', 'at nine.<|im_end|>ghost']) {
  shown += safe.push(piece)
}
shown += safe.flush()
// 'The vault opens at nine.'

scanner([options])

  • stop — markers that end the text. Everything from one on belongs to the template, and stopped names the marker that ended it, or is null. A stop marker outranks a suppression it lands inside, so an opener the model never closed cannot swallow what follows it.
  • suppress — [open, close] pairs. What sits between them is withheld. Where two openers start at the same place, the longer one wins.
const safe = scanner({ stop: markers.turnEnd, suppress: [['<think>', '</think>']] })

;['sure', '<think>hmm</think> the ', 'vault<|im_end|>ghost'].map((token) => safe.push(token))
// ['sure', ' the ', 'vault']
safe.stopped // '<|im_end|>'

push returns what is safe to show, which is never a marker: not a partial one, because a marker can straddle two pieces, so the longest suffix that could still become one is withheld until the next piece settles it; and not one that suppression assembled, because cutting a block out closes the gap between what stood either side of it, and <|im and _end|> around one are a marker the answer never carried. What a text says does not depend on the pieces it arrives in. flush returns what was held back and never confirmed — nothing, if the text ended inside a suppression, because what it was holding was never the answer. Flushing ends that suppression too, so the same scanner can take the next answer.

Splitting the text into two labelled channels — thinking against content — stays with the caller. It has to decide what an orphan close means, and whether an open that never closed replays as the answer; both are policy about a template rather than about text.

holdback(buffer, markers)

The length of the longest suffix of buffer that could still become one of markers.

holdback('the answer is <|im', ['<|im_end|>']) // 4 — hold '<|im' back until the next piece

The primitive under scanner, exported because a caller writing its own state machine needs it and should not write it twice.

asenion/intent

What a message is asking for, without asking a model. "Make me a document" names what it wants in its words, so which of a known set of intents a message wants is a lexical question: a model is the fallback for the messages the words leave open, not the first thing every message costs.

const { intents } = require('asenion/intent')

const classify = intents({
  'create-document': {
    examples: ['make me a document', 'draft a doc', 'write up a report'],
    patterns: ['called {name}'],
    nearMisses: ['how do I make a document']
  },
  'schedule-meeting': {
    examples: ['book a meeting', 'make a meeting with the team']
  }
})

classify('make me a doc called Q3 Plan')
// [{ intent: 'create-document', score: 1, margin: 0.8637…, words: ['make', 'doc', 'called'],
//    unknown: [], slots: { name: 'Q3 Plan' }, negated: false, nearMiss: null },
//  { intent: 'schedule-meeting', score: 0.1362…, margin: -0.8637…, words: ['make'],
//    unknown: ['q3', 'plan'], slots: {}, negated: false, nearMiss: null }]

The corpus is data — plain JSON, reviewed and versioned like code. A model can write it: asked for terse, misspelt phrasings of each intent and for the near misses of each, it does once, offline, what it would otherwise do on every message. Whoever writes the corpus decides what a phrasing routes to, so it is committed rather than generated at startup.

intents(corpus[, options])

Reads the corpus once and returns classify. Each intent is named by its key and holds:

  • examples — phrasings of it. Their words, closed-class ones aside, are the intent's vocabulary.
  • patterns — patterns whose {name} slots are the values it carries. A pattern's own words are vocabulary too, so called in called {name} says something.
  • nearMisses — phrasings that use its words and do not ask for it.

And the options:

  • scorer — overlap, the default, or bayes. Both return the same results; what differs is what score means.
  • verbs — the words the router's commands open on, for its patterns, as tag reads them. The corpus is not read for them: weather in london is an example of an intent, not a claim that weather is a verb, so called Travel and Weather still names a document whatever else the router knows.

classify(text)

Every intent text shares a word with, likeliest first. Empty when the corpus has no word for it.

  • score — from 0 to 1. Under overlap, the share of what the message says that this intent accounts for: a word weighs more the fewer intents use it, and a word the corpus has never heard of counts against every intent that does not hold it in a slot, unless it is one of the language's own words — please, now and the say nothing about a message, where a verb the corpus has never used, like send, does. Under bayes, the chance of this intent given the message, across the intents it shares a word with.
  • margin — the score less the best of the rest. The first result's margin is how far ahead it is, and that, not the score, is what to set a threshold on: 0.7 means nothing when second place is 0.68.
  • words — the words that counted for it, and unknown the words the corpus has never heard of that it does not hold in a slot. An unknown word is what the corpus is missing.
  • slots — what its patterns captured, as capture gives it.
  • negated — a not or never stands before one of its words, within the same clause: a comma, a dash or a sentence end is as far as one reaches, so never mind, make a doc is not negated.
  • nearMiss — the near miss the message is closer to than to any example, or null.
classify("don't make a document")[0].negated // true
classify('how do I make a document?')[0].nearMiss // 'how do I make a document'

The two scorers answer different questions. overlap says how much of the message an intent explains, so a message about something the corpus has never heard of scores low everywhere. bayes says which intent a message is, supposing it is one of them — it reads nothing into an unknown word, and weighs a word by how often an intent's examples use it rather than only by whether they do:

const corpus = {
  'create-document': { examples: ['make me a document', 'draft a doc'] },
  'schedule-meeting': { examples: ['make a meeting', 'make a call', 'book a meeting'] }
}

intents(corpus)('make something').map((r) => r.score) // [1, 1] — both use "make"
intents(corpus, { scorer: 'bayes' })('make something')[0].intent // 'schedule-meeting'

Each word counts once, however often the message repeats it, and every intent starts as likely as the next: how many examples an intent has is how hard it was to phrase, not how often it is asked for. With a small corpus the smoothing that lets an unseen pairing be possible is most of the evidence, so a bayes score reads low until the corpus is large enough to argue with it.

A negated or near-missed intent is still ranked, with its score, because what a message nearly asks for is the caller's to act on — to refuse it, to ask, or to hand it to a model. An overlap score is not a probability, and a bayes one is a probability only among the intents the message shares a word with: neither says a message asks for one of them at all. unknown and margin are what say that.

audit(corpus[, options])

What a corpus says about itself, before a message ever reaches it — for the corpus a model wrote, which is written fast, reads well, and is wrong in ways nobody reviewing it by eye will see. Takes the same options as intents, so it scores the way the classifier will.

const { audit } = require('asenion/intent')

audit({
  'create-document': {
    examples: ['make a doc', 'draft a doc', 'send a doc'],
    nearMisses: ['how do I make a doc']
  },
  'send-email': { examples: ['send an email', 'email the team', 'send it to the team'] }
})
// { ok: false,
//   problems: [
//     { problem: 'misread', intent: 'create-document', text: 'send a doc', other: 'send-email',
//       detail: 'it ties with send-email' },
//     { problem: 'unstopped', intent: 'create-document', text: 'how do I make a doc', other: null,
//       detail: 'nothing else in the corpus stops it reading as create-document' }],
//   accuracy: 0.8333…, threshold: 0, coverage: 0.8333…,
//   held: [{ intent: 'create-document', text: 'make a doc', got: 'create-document', score: 0.5,
//            margin: 0.5 }, …] }

Every example is held out and classified against the rest, which is what a message phrased like it meets: got is the intent it read as, and null where nothing else in the corpus shares its words — the corpus knows that phrasing only from itself, which is reported as unread. Every near miss is held out the same way.

| Problem | | | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | | misread | held out, the example reads as other, or ties with it | | unread | held out, the example reads as nothing: no other string in the corpus shares a word with it | | unstopped | held out, the near miss reads as its own intent and nothing flags it | | lookalike | a string carries a homoglyph, and teaches a word nobody types | | duplicate | an example is already one of its intent's, and counts its words twice | | conflict | an example is also one of other's examples, or one of its own near misses. Reported once, not again as the misreads it causes | | empty | none of an example's words say anything |

  • accuracy — the share of examples held out that read as their own intent.
  • threshold — the widest margin a misread won by. A classifier routing only above it would have routed none of the misreads.
  • coverage — the share of examples that read as their own intent by more than threshold: what routing above it would still have caught.

threshold and coverage are the corpus's own evidence for where to route without a model and where to ask one. They are measured on phrasings the corpus was written with, so they are the best the classifier will do, not what it will do on messages nobody thought of — which the model answering below the threshold, and a reviewed corpus that grows from what it answered, are for.

Performance

Measured on a 2.3 KB reply — a long answer, some 400 words — on Node 24. check runs every law it was given; the numbers below are one call, in microseconds.

| Call | | | ----------------------------------------- | --- | | check with has, excludes, oneLine | 102 | | check with three matches patterns | 215 | | fold | 39 | | normalize / tokens | 75 | | tag | 118 | | similarity | 178 | | quantities (2.1 KB, three quantities) | 52 | | atomic (the same text) | 132 | | atomic on one claim, 211 characters | 14 | | grounded against 50 trusted values | 0.4 | | scanner.push for one piece | 0.4 | | classify against 51 intents | 116 | | classify against 51 intents, bayes | 129 |

The fold is the cost of a check, so a check folds and splits its text once however many terms it is given, and tags it only where a matches pattern asks. A reply that is plain ASCII — nearly every English one — skips the Unicode passes entirely, and one that is not folds a character at a time through a memo. Between them that is the difference between 39 µs and the 300 µs it used to be.

test/perf.js pins the shape rather than the milliseconds, and holds quantities and atomic to it on ten shapes of text, among them runs of separators, joiners and space that lead nowhere: the same bytes cost the same as one text or as eight. the same bytes cost the same whether they arrive as one long text or eight short ones, forty terms cost about what one does, a scan holds a bounded buffer however many pieces arrive, stripping nested framing terminates, a pattern of many slots fits in time linear in the text, and an audit is linear in its examples, however many words they carry and however many one intent holds. A near miss closer than every example of its intent is compared with all of them, which is what saying so takes, and what a message costs classify too. Those ratios survive a slow CI box where an absolute bound would not.

Limits

English, and only what a small lexicon can carry: the closed classes, the commonest verbs, a set of suffix rules and the contextual ones above — enough to tag a short answer, not enough to parse prose. An unknown word is a noun unless something around it argues otherwise, so an open-class verb nobody named (barks) tags as a noun; the words a product's commands open on are the caller's verbs. Text in another language folds and tokenizes correctly and tags as nouns.

A particle is up, out, off or down, never in or on: sign in and coffee in the morning are spelled alike, so a command that closes on one needs its verb known.

atomic reads words, not meaning. It misses a second fact that has no number and no comma-led contrast (light winds and moderate rain), and it flags a second fact that is only a parenthesis (Bansko, with 9232 people, is a town). It is a cheap floor under a model that was asked for one fact at a time, not a replacement for asking.

quantities skips a number it cannot hold: 1.2.3 and a 400-digit run are spelled like numbers and are not ones a claim can compare. test/chaos.js reads hostile and generated text against both laws.

A verdict says an output failed a law it was given. It never says the laws hold. Everything here is stated in advance, so an adversary who knows the laws can satisfy every one of them and still send something you did not want — these are guardrails, not a boundary, and something that is a boundary belongs behind them.

Runs on Bare and Node from one source. Two runtime dependencies, unicode-to-plain-text and b4a, both of which run on both from one source and carry no dependencies of their own.

Roadmap

What is written down here does not exist yet. It is written down so the shape of check stays honest about what it has to carry, and so a law that stops being lexical does not need a new module to live in.

Law bands

Every law returns the same { rule, term, detail } failure whatever it cost to evaluate, so one laws object can be run cheaply in a unit test and in full in staging.

  • Deterministic — the words. Pure, synchronous, no I/O, no clock, no state.
  • Structural — pure on the same terms, but not lexical: the markers in asenion/guards and grounded ship; JSON and schema conformance, the shape of a secret or of PII do not yet.
  • Semantic — not a band a failure can carry yet, because nothing evaluates one: needs a model — does the reply answer what was asked, hold to the context it was given, decline what it was meant to decline. Async, non-deterministic, and validated by the same class of thing it validates — so it stays behind its own export and never becomes an implicit cost of calling check.

TODO

Nothing here is committed to a date. Each waits on a consumer that needs it, because an export with no caller is a guess about the second one.

  • [ ] JSON and schema conformance, as structural laws
  • [ ] the shape of a secret and of PII, as structural laws
  • [ ] semantic laws behind a judge the caller supplies, so that a model call never becomes an implicit cost of check

A verdict is itself input on the next turn: a judge that quotes what it rejected has handed the value back. Whatever the semantic band becomes has to answer that before it answers anything else.

License

Apache-2.0