The TDD loop
Rule generation is test-driven. The engine generates rules from your source text. Your test suite validates the output. Failing tests become the basis for targeted guidance — feedback that points to the specific source clause the engine missed. The loop repeats until all tests pass.Step 1 — Create a ruleset
If you completed Phase 2 (field vocabulary), you already have confirmed field names and aproject_id — write your test cases using those names and skip to Step 2.
If starting fresh on a single-section domain, you need test cases before creating the ruleset. Two approaches:
- Inspect an existing ruleset in the same domain with
aethis_schemato learn the field naming convention, then write tests using those names. - Make your best guess at field names, create the ruleset with preliminary tests, then run
aethis_discover_fieldsto confirm. If names don’t match, add corrected tests viaaethis_create_rulesetwith a new project — the cost is low.
Step 2 — Generate and test
> instead of ≥), and the school type restriction isn’t compiled. Each failure includes a hint pointing to the likely cause.
Step 3 — Refine with guidance
Add guidance that references the specific source clause:aethis_refine adds the guidance, then makes the minimal edit to fix the failing tests — seeded from the section’s active ruleset and keeping the passing tests green, rather than re-authoring the whole section. Returns (second attempt — all passing):
Guidance variants
Add guidance without regenerating
Useful when you want to accumulate several pieces of guidance before triggering a generation run:Check accumulated guidance
Domain-level guidance
Add once, applies to all projects in the domain — no need to repeat cross-section principles on each ruleset:Guidance examples
Real guidance from the UK Free School Meals household criteria section (11 tests, 8 qualifying routes). Each hint addresses a specific compilation gap.OR logic across routes
OR logic across routes
Problem: The engine treats all qualifying criteria as AND conditions — a household must meet all routes to qualify, when it should be any one.Guidance:
Compound AND within an OR route
Compound AND within an OR route
Problem: The Universal Credit route passes when
receives_universal_credit is true, ignoring the income cap.Guidance:Unconditional boolean overrides
Unconditional boolean overrides
Problem: Looked-after children are being checked against benefit criteria when they should bypass them entirely.Guidance:
Negative conditions (absence of a benefit)
Negative conditions (absence of a benefit)
Problem: The Child Tax Credit route qualifies anyone receiving CTC, missing the requirement that they must not also receive Working Tax Credit.Guidance:
Diagnosing a specific failure
If a test is failing but the hint isn’t clear enough, useaethis_explain_failure to get a deeper diagnosis:
Test coverage strategy
Good coverage catches failures before production:- Boundary values — test just below, at, and above every threshold (age 3, 4, 15, 16)
- Every Enum value — one test per enum value for each Enum field
- Every code path — combinations of fields that exercise a distinct branch
- Five tests minimum per section — more is better; sparse test suites let edge-case bugs through
Date arithmetic: years_between
Source text frequently expresses age or duration thresholds in terms of a date of birth or a start date (“must be 18 or over”, “at least 5 years’ continuous residence”). Never ask for or store an age field directly — dates drift out of sync with a stored age the moment time passes, and a re-run of the same ruleset on a later day silently changes the answer. Compile the threshold from the date field instead:
years_between(start, end) returns the number of completed whole years between two Date fields — true “has the Nth anniversary been reached on or before end” logic, leap-year correct (29 February birthdays included). It is not days_between(start, end) / 365, which drifts by roughly a day every four years and silently produces off-by-one-year answers near a birthday. Both helpers compile to the same compiled rule evaluation as every other operator in this reference (see What you can express for the full operator table) — there’s no separate date-math code path to reason about at decide time.
Step 4 — Publish
Once all tests pass:name is auto-derived from the section ID (e.g. child_eligibility → "Child Eligibility"). To override it, pass name in the publish call:
ruleset_id is now ready for aethis_decide. Published rulesets are locked and versioned.
Sources: budget, duplicates, and lifecycle
Generation builds its context from every active source on the project, so source hygiene directly affects rule quality. Token budget. Every upload response includesestimated_tokens per file, a project_estimated_tokens running total, and the generation_token_budget it must fit (the model’s context window minus headroom reserved for the generation loop). Before any generation starts, the engine counts the exact prompt — if it exceeds the budget, the request is rejected with 422 token_budget_exceeded (including the count, the budget, and the largest sources) before any model cost is incurred.
Duplicates. Re-uploading identical content (or a same-named file) is flagged in the response under possible_duplicates — surfaced, never silently merged. Conflicting versions of the same guidance are a correctness hazard: resolve them by superseding the stale copy.
Lifecycle. GET /projects/{project_id}/sources lists every source with its status; PATCH /projects/{project_id}/sources/{source_id} sets it:
active— included in generation (the default)superseded— replaced by a newer upload (optionally point at it withsuperseded_by); kept for provenance, excluded from generationreference_only— kept on the project, excluded from generation
DELETE removes a source outright — but a source cited by any persisted ruleset’s provenance returns 409 rather than orphaning those citations. Prefer superseded when generated rulesets already cite it. The retained bytes behind a source stay downloadable at GET /projects/{project_id}/sources/{source_id}/raw (scope projects:read); see Provenance and citations.
Grounding report
Every generated rule cites the source passages it is grounded in. After generation, the job (and thegenerate-and-test response) carries a provenance_report:
totals— how many citations resolved against the uploaded sources (verified), cited passages that don’t exist (flagged), and rules or fields with no citation (uncited)coverage— per source: how many passages are cited by at least one rule, plus a sample of passages nothing cites
source_targets. See Provenance and citations.
Handling generation timeouts
Rule generation takes 5–15 minutes for complex sections. If the client times out, the server continues generating. Do not re-trigger generation — it creates a duplicate run.- Wait 10–15 minutes
- Call
aethis_list_rulesets({ project_id: "proj_8CzLVwyx53rTGEJv" })to check if a new ruleset appeared - If a ruleset is present, run your test suite and publish
- If not, wait and check again
After all sections are published
If your domain has multiple sections, compose them into a rulebook — a container that references published rulesets and defines outcome logic.Create a rulebook
Via the REST API:Ruleset references
Eachruleset_ref has:
Outcome logic
Defines how section outcomes compose. Each section is assigned a letter (A, B, C…) in the order listed inruleset_refs.
Supported operators: AND, OR, NOT, parentheses for grouping.
robot_hints — guidance for a conversational agent
A rulebook optionally carries robot_hints: a flat map of beat name → natural-language guidance string, for a conversational agent (like Lisa) driving an applicant through the composed rulebook. Set it on create or update:
Values are free-text strings — there’s no further schema on the guidance itself, only on the key set.
robot_hints is additive and back-compat: a rulebook authored before this field existed simply returns robot_hints: null on /schema (below), so an older consumer doesn’t need to change anything.
Activate and evaluate
/decide endpoint accepts either a ruleset_id (single section) or a rulebook_id (composed multi-section). When evaluating against a rulebook, the response includes section_results showing each section’s individual decision.
The rulebook /schema response merges {rulebook_id, sections, fields, robot_hints, engine_version} across every composed section:
robot_hints— the rulebook’s guidance map (above), ornullfor a rulebook authored before the field existed.engine_version— the sameaethis-core@X.Y.Zstring echoed on every/decideresponse and at/openapi.json→info.version, so a consumer can record which engine build resolved this schema without a separate call. Useful for pinning replay: if you cache a schema response,engine_versiontells you whether a later/decideran against the same compiled build.