Files
aml/pkg/models/models.go
T
zeekayandHanzo Dev 7c314d0b30 planes: the five surfaces a monitoring programme is operated through
Lists, suppressions, activations, the field catalog and the model plane. Each is
its own package, each is Base-backed, and none of them can destroy anything —
disposal is pkg/retention's decision alone, per tenant, and every one of them has
a test that reads its own source for a call to Delete.

lists holds the institution's own allow and deny lists, by class, normalised once
so an address written two ways is one address. It reaches a rule as one term,
Listed(name, value), and it refuses both a list nobody declared and an empty
value: either would be a rule that is catalogued, reported as coverage, and
incapable of firing. Removing a value stops it matching and keeps the row, because
a list entry is the reason a payment was refused.

suppress holds the decisions to keep a detection out of a queue. Reason and
decider are required and neither is defaulted; a suppression naming neither a rule
nor a subject is refused, because that is a kill switch. Lifting keeps the row.
The narrowest cover wins, so a specific decision about one account is the one named
on the activation rather than the rule-wide one behind it.

watch writes every activation before anything happens to it, and marks the ones a
suppression covered or a fold matched rather than dropping them: a system that
discards what it decided not to show cannot say how much it is not showing. Action
is what the rule asked for, Response is what the plane concluded, and exactly two
declared mechanisms move it. A rung may only raise — lowering is a suppression by
another name — and elevation beats folding, because an activation that reached an
escalation rung is the escalation. The live feed is a convenience over the durable
rows; it drops rather than blocking ingest, and the drops are counted per tenant
and published on every poll.

dictionary reports what a tenant's payloads carry: declared fields discovered by
reflection over the transaction rather than from a second list, the tenant's own
Raw keys, fill, blindness, and a distinct count kept as a bitmap sketch — hashed
with the tenant, so two tenants' bitmaps cannot be compared, and saturating into
"no longer a count" rather than drifting down. No value is ever stored. It is
write-behind and says so: Pending is exactly what a restart would lose.

topology searches the space of model shapes over a tenant's own history, through
the detector's own learning path rather than a copy of it, and reports the learning
curve, the realised alert share against the stated appetite, where each candidate
went blind, and which features carried the signal. It has no store and imports
nothing that has one. It names NO winner where nothing has been judged: ranking
needs an outcome, and a ranking invented from unlabelled data would look like a
recommendation and be a preference. Ties go to the smaller model.

models keeps every search and every fitted state, because both are governed acts,
and adopts a fit into the live model with its decider recorded. The learned state
never leaves the store — mass counters describe where a tenant's activity is dense.
A fit installs only into a model of the same shape, which is what stops a tenant's
model being swapped underneath it by restoring a file.

Around them: anomaly.Learn exposes the assessment Assess was already computing and
throwing away, so a study of the model reads the same scores production does;
engine gains one provider and one vocabulary term; and the tenant key moves to
pkg/brand, where the brand registry is, so the five new planes hold the same shape
at their own write boundary instead of each growing an opinion about what a tenant
is. api/tenant.go's qualify and qualified are now aliases of it, not a second copy.

Every operation is func(ctx, org, *In) (*Out, error): the tenant is an argument and
never something the operation resolves for itself. api/typed.go is the whole of
what turns one into a route — query into the input for a GET, body for a POST, the
path bound last and winning — and api/planes.go is a routing table with no handler
bodies in it, which a test reads as source. The contract is the Go types and
nothing else, so a zip face over the same functions restates none of it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:37:20 -07:00

163 lines
5.9 KiB
Go

// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
// Package models keeps the record of what a tenant's model is, and of every
// study that produced it.
//
// Two things are kept, and both are kept because both are governed acts.
//
// A RUN is a search over the space of model shapes (pkg/topology) against this
// tenant's own history. JMLSG 5.7.18 requires new detection to be implemented and
// tested before live activation and FCG 3.2.5A requires a retirement to be
// justified against the outgoing configuration's performance; a search is how
// both questions get an answer, and an answer nobody can retrieve six months
// later did not answer them. The run holds the space asked for, the options,
// every trial, and the seed — so the whole thing can be re-run and checked rather
// than believed.
//
// A FIT is learned state built from this tenant's history under one shape. It is
// what makes a recommendation actionable: adopt it and the model starts warm
// rather than blind. Adoption is recorded with its decider, because a model that
// changed and cannot say who changed it is a control nobody owns.
//
// # The learned state never leaves the tenant's store
//
// A fit's snapshot is written and read here and is not on any answer this package
// returns. Mass counters describe where a tenant's activity is dense; handing
// them out over an API would publish the shape of an institution's customer
// behaviour to whoever holds a token, which is a disclosure the alerts themselves
// are careful not to make. Callers get the digest, the trial and whether the fit
// is adoptable — everything needed to decide, and nothing that describes the
// customers.
//
// Nothing here deletes and nothing evicts, on the same terms as every other
// record plane: disposal is pkg/retention's decision alone.
package models
import (
"errors"
"time"
"github.com/luxfi/aml/pkg/topology"
)
// Errors.
var (
ErrNoHistory = errors.New("models: no history source is wired, so a search has nothing to replay")
ErrNoModel = errors.New("models: no live model is wired, so nothing can be adopted into one")
ErrNotHere = errors.New("models: no such run")
ErrNoFit = errors.New("models: no such fit")
ErrDecider = errors.New("models: no decider, so the record names nobody's decision")
ErrShape = errors.New("models: the fitted shape is not the shape the model is running")
ErrAdopted = errors.New("models: already adopted")
ErrStore = errors.New("models: store")
)
// Summary is a run as a list reads it: enough to choose one to open, and none of
// the trials.
//
// A run over a full grid carries a curve per candidate, which is the right size
// for one answer and the wrong size for a page of twenty. The two are separate
// shapes rather than one shape with a flag, so a list can never accidentally be
// the heavy read.
type Summary struct {
ID string `json:"id"`
At time.Time `json:"at"`
By string `json:"by"`
Trials int `json:"trials"`
Events int `json:"events"`
Judged int `json:"judged"`
Elapsed time.Duration `json:"elapsed"`
// Winner is the shape the run recommended, and Refusal is why it recommended
// none. Exactly one of them is set, on the summary as on the report.
Winner *topology.Topology `json:"winner,omitempty"`
Refusal string `json:"refusal,omitempty"`
}
// Run is a whole search, kept.
type Run struct {
ID string `json:"id"`
Org string `json:"org"`
At time.Time `json:"at"`
By string `json:"by"`
Space topology.Space `json:"space"`
Options topology.Options `json:"options"`
Report topology.Report `json:"report"`
}
// Fit is learned state built under one shape, as a caller sees it.
//
// The snapshot itself is deliberately absent — see the package doc. Digest is
// what identifies the shape, and Adoptable says whether the live model is running
// that shape today.
type Fit struct {
ID string `json:"id"`
Org string `json:"org"`
At time.Time `json:"at"`
By string `json:"by"`
Topology topology.Topology `json:"topology"`
Digest string `json:"digest"`
Trial topology.Trial `json:"trial"`
// Adoptable is whether this fit's shape matches the model this deployment is
// running. A fit of a different shape is a recommendation to change the
// deployment, not something that can be installed by restoring a file.
Adoptable bool `json:"adoptable"`
Adopted time.Time `json:"adopted,omitzero"`
AdoptedBy string `json:"adopted_by,omitempty"`
}
// The typed operations.
type (
// SearchIn runs a search over this tenant's own history.
SearchIn struct {
Space topology.Space `json:"space"`
Options topology.Options `json:"options,omitempty"`
By string `json:"by"`
}
// RefIn names one run or one fit.
RefIn struct {
ID string `json:"id"`
}
// RunsIn lists this tenant's runs, most recent first.
RunsIn struct {
Limit int `json:"limit,omitempty"`
}
// Runs is the answer to RunsIn.
Runs struct {
Runs []Summary `json:"runs"`
Cut bool `json:"cut,omitempty"`
}
// FitIn builds learned state under one shape.
FitIn struct {
Topology topology.Topology `json:"topology"`
Options topology.Options `json:"options,omitempty"`
By string `json:"by"`
}
// FitsIn lists this tenant's fits, most recent first.
FitsIn struct {
Limit int `json:"limit,omitempty"`
}
// Fits is the answer to FitsIn.
Fits struct {
Fits []Fit `json:"fits"`
Cut bool `json:"cut,omitempty"`
}
// AdoptIn installs a fit into the live model.
AdoptIn struct {
ID string `json:"id"`
Reason string `json:"reason"`
By string `json:"by"`
}
)
// DefaultLimit is how many rows a list returns when the caller names no bound.
const DefaultLimit = 50