๐Ÿ’ณ Secure Payment

Full-Service Web & Software Agency ยท Klamath Falls and Redding

Geometric reasoning as data

Work with Sean

Geometric reasoning, in this course, starts from a declared design space: every behavior to build or measure is a point in it, one level for each factor, with a distance between any two points. This lesson builds that space as data, with the evidence, families and timelines that live in it, and the lessons after it read what it builds.

This is the data that crosses the joins named in The AI protocol map. The examples come from a coffee cart that answers questions about its menu, and every trait, word and score in them is invented for the lesson. The code is TypeScript, tested with Vitest. The design file’s format is the module’s shared fixture: the Python lessons read it too, and a test on each side holds both readers to the same output.

Three senses of one phrase

“Geometric reasoning” names at least three different things in circulation, and a neighboring field shares half the name. What the space is, and what gets measured in it, tells them apart:

  • A declared design space. The space is a set of discrete factors chosen in advance, such as how long an answer runs or how boldly it commits, and its points are every combination of their levels. A behavior is measured at each point, and the distance between two points is how many factors differ. This is the course’s sense.
  • The shape of a model’s representations. The space is the vectors inside a model as it works, and reasoning shows up as a path through them. Zhou, Wang, Yin, Zhou and Zhang’s The Geometry of Reasoning: Flowing Logics in Representation Space (ICLR 2026) reads such paths by their position, velocity and curvature.
  • Geometry itself. The plain sense: a model solves geometry problems. DeepMind’s AlphaGeometry, in Trinh, Wu, Le, He and Luong’s Solving olympiad geometry without human demonstrations (Nature, 2024), proves olympiad geometry theorems.
  • Symmetry in learning, the neighbor. Geometric deep learning, in the 2021 work by Bronstein, Bruna, Cohen and Veličković, shares half the name. Its space is the data’s own domain, such as a grid or a graph, and it builds a model’s architecture around the transformations that leave that domain unchanged.

The module builds on the first sense. The other three stay out of it, named here so a reader who meets the phrase elsewhere can tell which one is meant.

Factors, points and distance

The coffee cart’s answers vary on three traits, each with two levels: brief or detailed, formal or casual, cautious or bold. The design file, shared/coffee-cart.design.json, declares them once, in order, with a table of invented values:

{
  "$schema": "./design.schema.json",
  "name": "coffee-cart-answers",
  "note": "A coffee cart's menu answers, on three invented two-level traits. Every value in the table is invented for the lesson.",
  "factors": [
    { "name": "length", "levels": ["brief", "detailed"] },
    { "name": "register", "levels": ["formal", "casual"] },
    { "name": "certainty", "levels": ["cautious", "bold"] }
  ],
  "table": {
    "measure": "invented score",
    "rows": [
      { "point": "000", "value": 0.9 },
      { "point": "001", "value": 0.8 },
      { "point": "010", "value": 0.85 },
      { "point": "011", "value": 0.7 },
      { "point": "100", "value": 0.88 },
      { "point": "101", "value": 0.75 },
      { "point": "110", "value": 0.8 },
      { "point": "111", "value": 0.6 }
    ]
  }
}

A point takes one level of each factor, written as a digit: 0 for the first level, 1 for the second. So 011 is brief, casual and bold. Three two-level factors give 2³ = 8 points, a two-level full factorial in the terms of the NIST/SEMATECH e-Handbook. The distance between two points is how many factors they differ in, their Hamming distance, after R. W. Hamming’s 1950 paper on error-correcting codes.

Nothing derived is stored. The file holds the factors and the table, and the points, distances and counts come from the factors every time, so they can’t drift from them; the lecture Redux Toolkit and RTK Query Best Practices gives state the same rule (Derive views with selectors). The shared reader, src/shared/design.ts, parses the file into readonly types once, at the edge, and derives the rest:

// src/shared/design.ts, the three functions this lesson uses
/** Every point of a d-factor design, in order: 000, 001, 010, ... */
export const points = (d: number): readonly string[] =>
  Array.from({ length: 2 ** d }, (_, i) => i.toString(2).padStart(d, "0"));

/** How many factors two points differ in. */
export const hamming = (a: string, b: string): number => {
  if (a.length !== b.length) throw new Error("points must have the same number of factors");
  return [...a].filter((bit, i) => bit !== b[i]).length;
};

export const describe = (design: Design, point: string): string =>
  design.factors.map((factor, i) => factor.levels[point[i] === "1" ? 1 : 0]).join(", ");

This lesson adds the two relations the later lessons need: the neighbors of a point, and the one factor a step to a neighbor changes.

// src/geometric-reasoning-as-data/space.ts
// Relations between a design's points, derived from the points alone.
import { hamming, points } from "../shared/design.ts";

/** The points one factor away. */
export const neighbors = (point: string): readonly string[] =>
  points(point.length).filter((other) => hamming(point, other) === 1);

/** The one factor two points differ in; any other step is an error. */
export const flipped = (a: string, b: string): number => {
  if (hamming(a, b) !== 1) throw new Error(`${a} and ${b} are not one factor apart`);
  return [...a].findIndex((bit, i) => bit !== b[i]);
};

Every point has one neighbor for each factor, and a step to a neighbor moves one trait. From 011, brief, casual and bold, the step to 001 moves the register, to 010 the certainty and to 111 the length. A step across two factors is refused, since it can’t say which trait made the difference:

$ npx vitest run src/geometric-reasoning-as-data/space.test.ts --reporter=verbose | grep -E 'โœ“|Tests'
 โœ“ src/geometric-reasoning-as-data/space.test.ts > the space a design declares > Given any point, When its neighbors are listed, Then there is one per factor, each one factor away 13ms
 โœ“ src/geometric-reasoning-as-data/space.test.ts > the space a design declares > Given 011 and each of its neighbors, When the step is read, Then it names the one factor that moved 1ms
 โœ“ src/geometric-reasoning-as-data/space.test.ts > the space a design declares > Given two points two factors apart, When the step is read, Then it is refused 1ms
      Tests  3 passed (3)

Drawn by how many traits sit at their second level, the cube stands in rows of 1, 3, 3 and 1, and the table beside it names each point once:

The coffee cart’s cube beside its table Left, the eight points of the coffee cart’s three traits in rows of 1, 3, 3 and 1, from 000 at the top to 111 at the bottom, with a line between every two points one trait apart. Lines that change the certainty run down to the left, lines that change the register run straight down and lines that change the length run down to the right, as the key below the drawing shows. Right, the table lists each point once with its invented value. Point 011 is outlined in both, and its value is 0.7. 000001010100011101110111pointvalue0000.90010.80100.850110.71000.881010.751100.81110.6certaintyregisterlength
The coffee cart’s cube beside its table. Each line joins two points one trait apart, and lines that change the same trait run parallel, as the key shows. Point 011, brief, casual and bold, is outlined in violet in both.

A design measures coverage, not quality, and its factors are chosen for what a release must not get worse at. A trait that matters less is held fixed rather than added.

Evidence as a fact graph

A behavior is measured on evidence, and the evidence is data too: a list of facts, each one edge in a graph. The facts are written in invented words, so what a model already knows about the world can’t stand in for the premises. PrOntoQA (Saparov and He, ICLR 2023) can generate its reasoning questions over a fictional ontology, so that what a model knows of the world doesn’t decide the answer.

An oracle labels every answer, so none is typed by hand. It chains forward: from the subject, it follows every fact that holds until nothing new is reached, then reads off yes, no or unknown.

// src/geometric-reasoning-as-data/facts.ts
// Evidence as a graph of facts in invented words, and the oracle that reads it.

/** One edge. From "the brintepet" it is about one thing; from any other word, about every member. */
export type Fact = { readonly from: string; readonly to: string; readonly holds: boolean };
export type Answer = "yes" | "no" | "unknown";

/** Forward chaining: follow every fact that holds out from the subject, until nothing new is reached. */
export const entails = (facts: readonly Fact[], subject: string, property: string): Answer => {
  const reached = new Set([subject]);
  for (let grew = true; grew; ) {
    grew = false;
    for (const f of facts)
      if (f.holds && reached.has(f.from) && !reached.has(f.to)) {
        reached.add(f.to);
        grew = true;
      }
  }
  const denied = facts.some((f) => !f.holds && f.to === property && reached.has(f.from));
  if (reached.has(property) && denied) throw new Error(`the facts say both yes and no to ${property}`);
  return reached.has(property) ? "yes" : denied ? "no" : "unknown";
};

/** One fact as a sentence; the property is an adjective, every other word a noun. */
export const sentence = (f: Fact, property: string): string => {
  const what = f.to === property ? f.to : `a ${f.to}`;
  if (f.from.startsWith("the ")) return `The ${f.from.slice(4)} is ${f.holds ? "" : "not "}${what}.`;
  return `${f.holds ? "Every" : "No"} ${f.from} is ${what}.`;
};

A perturbation pair is two fact sheets that differ in one fact. Flip a load-bearing fact, one on the path from the subject to the answer, and the answer must move; flip a distractor, and it must not. Contrast sets (Gardner and others, Findings of EMNLP 2020) perturb test items by hand in small ways that typically change the gold label. A generator and an oracle make the same move in code.

The pair record holds everything a reviewer needs to trust it: both sheets, which fact flipped, the hop count, the distractor count, both answers and its provenance (the generator, the seed and the rule). Its one constructor labels both sides with the oracle and refuses a pair whose sheets differ in length, or in anything but exactly one fact.

// src/geometric-reasoning-as-data/pairs.ts
// A perturbation pair: two fact sheets that differ in one fact, both labelled by the oracle.
import { entails, type Answer, type Fact } from "./facts.ts";

export type Pair = {
  readonly subject: string;
  readonly property: string;
  readonly facts: readonly Fact[];
  readonly perturbed: readonly Fact[];
  readonly flipped: number;
  readonly hops: number;
  readonly distractors: number;
  readonly answers: { readonly canonical: Answer; readonly perturbed: Answer };
  readonly provenance: { readonly generator: string; readonly seed: number; readonly rule: string };
};

/** The one constructor: it labels both sides with the oracle, and refuses sheets of
    different lengths or any pair where anything but exactly one fact differs. */
export const pair = (p: Omit<Pair, "flipped" | "answers">): Pair => {
  const same = (a?: Fact, b?: Fact) => a?.from === b?.from && a?.to === b?.to && a?.holds === b?.holds;
  if (p.facts.length !== p.perturbed.length)
    throw new Error(`a pair's sheets have ${p.facts.length} and ${p.perturbed.length} facts`);
  const changed = p.facts.flatMap((f, i) => (same(f, p.perturbed[i]) ? [] : [i]));
  if (changed.length !== 1 || changed[0] === undefined)
    throw new Error(`a pair differs in exactly one fact, not ${changed.length}`);
  const answers = {
    canonical: entails(p.facts, p.subject, p.property),
    perturbed: entails(p.perturbed, p.subject, p.property),
  };
  return { ...p, flipped: changed[0], answers };
};

export const flip = (facts: readonly Fact[], i: number): readonly Fact[] =>
  facts.map((f, j) => (j === i ? { ...f, holds: !f.holds } : f));

/** splitmix32, a small public seeded generator: the same seed always gives the same pair. */
const seeded = (seed: number) => () => {
  seed = (seed + 0x9e3779b9) | 0;
  let z = Math.imul(seed ^ (seed >>> 16), 0x21f0aaad);
  z = Math.imul(z ^ (z >>> 15), 0x735a2d97);
  return ((z ^ (z >>> 15)) >>> 0) / 2 ** 32;
};

const SYLLABLES = ["quil", "mor", "dun", "vash", "tep", "zor", "brin", "kell", "lom", "sarr"];

/** A chain of `hops` facts from an invented drink to the property, with distractors among them. */
export const makePair = (seed: number, hops: number, distractors: number, property = "dairy-free"): Pair => {
  const random = seeded(seed);
  const pick = <T>(xs: readonly T[]): T => xs[Math.floor(random() * xs.length)] as T;
  const words = new Set<string>();
  while (words.size < hops + distractors) {
    const [a, b] = [pick(SYLLABLES), pick(SYLLABLES)];
    if (a !== b) words.add(`${a}${b}et`);
  }
  const [first = "", ...rest] = [...words];
  const chain = [`the ${first}`, ...rest.slice(0, hops - 1), property];
  const spare = rest.slice(hops - 1);
  // On the path every fact holds, except perhaps the last, which decides yes or no.
  const onPath: Fact[] = chain.slice(1).map((to, i) => ({
    from: chain[i] ?? "",
    to,
    holds: i < hops - 1 || random() < 0.5,
  }));
  // Off the path, facts start from words the subject never reaches.
  const offPath: Fact[] = spare.map((from) => {
    const others = spare.filter((w) => w !== from);
    const to = others.length > 0 && random() < 0.5 ? pick(others) : property;
    return { from, to, holds: random() < 0.5 };
  });
  const facts = [...onPath, ...offPath]
    .map((f) => ({ f, at: random() }))
    .sort((a, b) => a.at - b.at)
    .map(({ f }) => f);
  const loadBearing = facts.indexOf(pick(onPath));
  const provenance = { generator: "invented-chain", seed, rule: "flip one load-bearing fact" };
  const perturbed = flip(facts, loadBearing);
  return pair({ subject: chain[0] ?? "", property, facts, perturbed, hops, distractors, provenance });
};

One pair, printed the way a reviewer reads it:

$ node src/geometric-reasoning-as-data/pair.ts
invented-chain, seed 8: 3 hops, 2 distractors, flip one load-bearing fact
  0 Every zorquilet is a zorbrinet.
  1 No zorbrinet is dairy-free.
  2 Every lomkellet is a kellbrinet.
  3 Every kellbrinet is dairy-free.
  4 The brintepet is a lomkellet.
question: Is the brintepet dairy-free?
fact 3 becomes: No kellbrinet is dairy-free.
answers: yes, then no

Drawn as a graph, the proof is one path from the subject to the property, and the distractors never touch it:

A fact graph and its twin, one fact apart On the left, a highlighted proof path runs from the brintepet to lomkellet, to kellbrinet and down to dairy-free. On the right, two greyed distractors, zorquilet and zorbrinet, form a chain of their own that ends in a line labelled no into dairy-free. The last edge of the proof path is labelled: it was every, and in the perturbed sheet it is no. Below, the question asks whether the brintepet is dairy-free; the canonical sheet answers yes and the perturbed sheet answers no. the brintepetlomkelletkellbrinetdairy-freezorquiletzorbrinetwas: everynow: nonoIs the brintepet dairy-free?canonical: yesperturbed: no
The pair above as a graph. The violet path is the proof, three hops from the brintepet to dairy-free. The grey facts are distractors, a chain of their own the subject never reaches. Flipping the proof’s last edge from every to no moves the answer from yes to no.

The tests hold the generator to its invariants over 600 seeded pairs, from one hop to five, each with none, two or four distractors. The third is the strongest: flip each fact of a sheet alone, and exactly the hops move the answer, so the counts the record states are counted again by the oracle.

$ npx vitest run src/geometric-reasoning-as-data/pairs.test.ts --reporter=verbose | grep -E 'โœ“|Tests'
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given seeded pairs, When each is built, Then exactly one fact differs, at the recorded index 20ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given a load-bearing flip, When the oracle reads both sheets, Then its two answers differ 5ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given every fact of a sheet, When each is flipped alone, Then only the hops move the answer 9ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given the same seed, When a pair is made twice, Then both are the same pair 1ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given two sheets two facts apart, When they are made a pair, Then the constructor refuses 1ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given a perturbed sheet with one fact more, When they are made a pair, Then the constructor names both lengths 0ms
 โœ“ src/geometric-reasoning-as-data/pairs.test.ts > perturbation pairs > Given a sheet that says both yes and no, When the oracle reads it, Then it refuses to answer 0ms
      Tests  7 passed (7)

Matched families

A matched family is one fact sheet answered at every point of a design. The sheet, the question and the evidence stay fixed and only the point moves, so any difference between two answers belongs to the traits that differ.

Every field of an answer is declared in advance as one of two kinds. Invariant fields are the same at every point: here, the facts and the answer. Expressive fields move with the voice: here, one, the tone. A field nobody declared is a fault, never quietly treated as either kind.

// src/geometric-reasoning-as-data/family.ts
// A matched family: one fact sheet answered at every point of a design.
import { points } from "../shared/design.ts";

export type Value = string | readonly string[];
export type Member = { readonly point: string; readonly output: Readonly<Record<string, Value>> };
export type Declared = { readonly invariant: readonly string[]; readonly expressive: readonly string[] };

/** A value as a key; a list is compared sorted, so its order never matters, though a repeat still does. */
const key = (v: Value | undefined): string =>
  JSON.stringify(typeof v === "string" || v === undefined ? v : [...v].sort());

/** Every fault in a family, as data: a point missing or answered twice, a field nobody declared,
 *  an invariant field that moved, two points that say it the same way. An empty list is a family that holds. */
export const validateFamily = (d: number, declared: Declared, family: readonly Member[]): readonly string[] => {
  const faults: string[] = [];
  for (const p of points(d)) {
    const n = family.filter((m) => m.point === p).length;
    if (n !== 1) faults.push(n === 0 ? `point ${p} is missing` : `point ${p} is answered ${n} times`);
  }
  const known = new Set([...declared.invariant, ...declared.expressive]);
  for (const m of family)
    for (const f of Object.keys(m.output)) if (!known.has(f)) faults.push(`${m.point}: ${f} is not declared`);
  const [first, ...rest] = family;
  for (const f of declared.invariant)
    for (const m of rest)
      if (first && key(m.output[f]) !== key(first.output[f]))
        faults.push(`${m.point}: ${f} differs from ${first.point}`);
  const voices = new Map<string, string>();
  for (const m of family) {
    const voice = declared.expressive.map((f) => key(m.output[f])).join("|");
    const twin = voices.get(voice);
    if (twin !== undefined && twin !== m.point) faults.push(`${twin} and ${m.point} say it the same way`);
    voices.set(voice, m.point);
  }
  return faults;
};

The validator returns every fault as data, the way the lecture Practical Applications of Functional Programming collects every failure instead of stopping at the first (Accumulate every error with validation). A family with a missing point and a changed answer needs both repairs, and a report that names only one sends the generator round twice.

The contract starts as a feature, written in Gherkin the way Given-When-Then (Gherkin) syntax in BDD teaches:

# src/geometric-reasoning-as-data/family.feature
Feature: A matched family holds the facts and moves the voice
  The coffee cart's dairy-free latte sheet, answered at every point of its three traits.
  The facts and the answer are invariant. The tone is expressive.

  Scenario: A family that holds the facts and moves the voice has no faults
    Given the sheet answered once at each of the 8 points
    When the family is validated
    Then no fault is reported

  Scenario: Every fault is reported at once
    Given the family with 111 missing and 000 answered twice
    And the answer at 101 offering whole milk
    When the family is validated
    Then all three faults are reported together

  Scenario: An undeclared field is a fault, never a default
    Given an answer at 010 with a greeting nobody declared
    When the family is validated
    Then the undeclared field is reported

  Scenario: Two points that say it the same way are a fault
    Given the answer at 001 in the same tone as the answer at 000
    When the family is validated
    Then both points are named

The tests take their names from the feature file, and one more test fails if a scenario has no test. The second scenario expects exactly three faults in one report: point 000 is answered 2 times, point 111 is missing and 101: answer differs from 000.

$ npx vitest run src/geometric-reasoning-as-data/family.test.ts --reporter=verbose | grep -E 'โœ“|Tests'
 โœ“ src/geometric-reasoning-as-data/family.test.ts > Feature: A matched family holds the facts and moves the voice > Scenario: A family that holds the facts and moves the voice has no faults 2ms
 โœ“ src/geometric-reasoning-as-data/family.test.ts > Feature: A matched family holds the facts and moves the voice > Scenario: Every fault is reported at once 1ms
 โœ“ src/geometric-reasoning-as-data/family.test.ts > Feature: A matched family holds the facts and moves the voice > Scenario: An undeclared field is a fault, never a default 1ms
 โœ“ src/geometric-reasoning-as-data/family.test.ts > Feature: A matched family holds the facts and moves the voice > Scenario: Two points that say it the same way are a fault 0ms
 โœ“ src/geometric-reasoning-as-data/family.test.ts > Feature: A matched family holds the facts and moves the voice > has a test for every scenario in family.feature 0ms
      Tests  5 passed (5)

Drawn as a family, the feature’s latte sheet holds one band fixed at every point and lets the rest move:

A matched family One fact sheet at the top fans out to eight answers, one at each point from 000 to 111. Below them, one highlighted band runs under all eight: the invariant fields, the same at every point: the facts, that oat milk is dairy-free and whole milk isn’t, and the answer, a latte with oat milk. Below that, each point has its own box naming its tone, t1 at 000 through t8 at 111, so no two points share one. one fact sheet000001010011100101110111invariant: the facts and the answeroat milk is dairy-free; whole isn’tanswer: a latte with oat milkexpressive: the tonet1t2t3t4t5t6t7t8
The latte sheet as a family. The violet band is locked: the same facts and the same answer at every point. Below it, t numbers the eight tones, one for each point, and no two points share one.

Evidence in order

Evidence arrives in order, and an answer has to follow it. The oat milk is in stock at 7:00, a customer asks about the hours at 8:30, the last carton goes at 9:15, and the 10:40 delivery brings almond milk and no oat milk. At each step the answer should match the latest evidence: in stock, still in stock after the question, then sold out, and still sold out once the delivery is in.

Each step’s context is every event so far, in order, taken from the timeline itself. A chat request carries its conversation the same way: Claude’s Messages API, for one, is stateless, and every request sends the whole of it.

// src/geometric-reasoning-as-data/timeline.ts
// Evidence in order: each step's context is every event so far, taken from the timeline itself.
export type Event = { readonly at: string; readonly says: string; readonly inStock?: boolean };

/** Each step's context: every event so far, in order, built from the timeline alone. */
export const contexts = (events: readonly Event[]): readonly (readonly Event[])[] =>
  events.map((_, i) => events.slice(0, i + 1));

/** What an answer should commit to: the latest evidence, or unknown before there is any. */
export const expected = (context: readonly Event[]): boolean | "unknown" =>
  context.findLast((e) => e.inStock !== undefined)?.inStock ?? "unknown";
$ npx vitest run src/geometric-reasoning-as-data/timeline.test.ts --reporter=verbose | grep -E 'โœ“|Tests'
 โœ“ src/geometric-reasoning-as-data/timeline.test.ts > evidence in order > Given in stock, then a question, then sold out, then a delivery without it, When each step is read, Then it expects the latest evidence 7ms
 โœ“ src/geometric-reasoning-as-data/timeline.test.ts > evidence in order > Given no evidence yet, When the first step is read, Then the answer is unknown 1ms
 โœ“ src/geometric-reasoning-as-data/timeline.test.ts > evidence in order > Given any step, When its context is built, Then it holds every earlier event in order and nothing later 2ms
      Tests  3 passed (3)

Measuring reasoning on the geometry reads a factorial table point by point, and checks that an answer moves only for evidence.

Tables cover every point, and prompts use words

A measurement table covers every point exactly once. The shared reader refuses a design whose table misses a point or holds a point of the wrong length, and reports both at once.

A prompt asks for a voice in plain words read from the design, such as “Answer in a brief, casual, bold voice”, because a point like 011 means nothing to a reader outside the design:

// src/geometric-reasoning-as-data/table.ts
// A prompt names the voice in words, read from the design.
import { describe, type Design } from "../shared/design.ts";

/** The request a model sees: the voice in plain words, never its point. */
export const ask = (design: Design, point: string, question: string): string =>
  `Answer in a ${describe(design, point)} voice. ${question}`;
$ npx vitest run src/geometric-reasoning-as-data/table.test.ts --reporter=verbose | grep -E 'โœ“|Tests'
 โœ“ src/geometric-reasoning-as-data/table.test.ts > tables and prompts > Given a table with 111 missing and a four-factor point, When it is read, Then both are refused 2ms
 โœ“ src/geometric-reasoning-as-data/table.test.ts > tables and prompts > Given the point 011, When its prompt is rendered, Then it asks for the voice in words 1ms
 โœ“ src/geometric-reasoning-as-data/table.test.ts > tables and prompts > Given every point, When its prompt is rendered, Then no two points ask for the same voice 0ms
      Tests  3 passed (3)

That is the data the module’s joins carry. The tandem harness puts it to work beside a model: it composes a prompt from a point and a fact sheet, and checks what comes back before anything leaves.

Copyright Sean Paul Payne Dinwiddie
All Rights Reserved