Turning an invalid facet value into an offer to fix it (Spec 023).
status:activ on a closed-set facet is a typo one character from working.
ADR-012
already catches it, keeps it out of the query, and records why it failed;
this module is what turns that record into something actionable.
This is validation-time, not zero-results recovery
For a closed-set facet the query never ran with activ in it — there are no
zero results to recover from. The correction belongs on the error pill as the
value is typed, before any dispatch, and every candidate comes from the
closed set the reason already carries. Suggesting alternatives for a
:string facet is a genuinely different mechanism (fuzzy matching against
real data, a provider capability) and is deliberately not attempted here.
Two kinds of help
candidates/5 guesses: it ranks the facet's own values against what was
typed. explain/4 doesn't guess — it states the expected shape, and where a
correct answer exists it offers that answer as a mechanical fix. A reversed
range and an out-of-bounds number don't need a suggestion, they need a swap
and a clamp, and offering the answer beats offering advice.
Nothing here ever rewrites a value on its own. A filter that quietly means something other than what is on screen is worse than one that is visibly wrong, so a correction is always offered and always accepted explicitly.
Summary
Types
A mechanical fix with a known-correct answer, as opposed to a guess.
A suggested replacement value.
Functions
Ranked replacement candidates for an invalid value, best first.
The message for an invalid value, and a mechanical fix when one exists.
The canonical token text a chosen candidate or fix should be spliced in as.
Types
Functions
Ranked replacement candidates for an invalid value, best first.
Only reasons carrying a closed set produce candidates — in practice
:not_in_values, whose params hold the facet's whole value list. Everything
else returns [], and the caller falls back to explain/4's statement of
shape.
Matching runs against both the value's key and its label, so estab
finds :established even when its label reads "Up and coming", and coming
finds it too. Candidates below the similarity floor are dropped entirely
rather than padded out to the limit.
Options
:limit— how many to return (default3).:floor— minimum similarity,0.0..1.0(default0.75).
Examples
iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active, :inactive])
...>
...> [top | _] =
...> Flicker.Correction.candidates(facet, "activ", :not_in_values, %{
...> values: [:active, :inactive]
...> })
...>
...> {top.value, top.label}
{:active, "active"}
iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active, :inactive])
...>
...> Flicker.Correction.candidates(facet, "banana", :not_in_values, %{
...> values: [:active, :inactive]
...> })
[]
iex> facet = Flicker.Facet.new(key: :price, type: :integer)
...> Flicker.Correction.candidates(facet, "abc", :bad_integer, %{value: "abc"})
[]
The message for an invalid value, and a mechanical fix when one exists.
Returns %{message: String.t(), fix: fix()}. The message comes from
Flicker.Messages
(ADR-009) —
this module produces no English of its own — and :fix is non-nil only where
the correct answer is computable rather than guessable:
{:swap, range}for a reversed range: the same endpoints, the right way round.{:clamp, value}for an out-of-bounds number: the nearest permitted value.{:replace, value}for anything else with a single obvious answer.
Options
:messages— aFlicker.Messagesoverride module.
Examples
iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
...> params = %{from: ~D[2026-07-30], to: ~D[2026-07-01]}
...> %{fix: {:swap, range}} = Flicker.Correction.explain(facet, :reversed_range, params)
...> {range.from, range.to}
{~D[2026-07-01], ~D[2026-07-30]}
iex> facet = Flicker.Facet.new(key: :price, type: :integer, bounds: %{min: 0, max: 500})
...> Flicker.Correction.explain(facet, :out_of_bounds, %{min: 0, max: 500, value: 900}).fix
{:clamp, 500}
iex> facet = Flicker.Facet.new(key: :created, type: :date)
...>
...> %{message: message, fix: fix} =
...> Flicker.Correction.explain(facet, :bad_date, %{value: "nope"})
...>
...> {message, fix}
{"expected a date like 2026-07-01, today, or 7d", nil}
@spec to_token(Flicker.Facet.t(), t() | fix()) :: String.t() | nil
The canonical token text a chosen candidate or fix should be spliced in as.
Everything a correction can produce has to serialise back to a token, exactly like an editor's output (ADR-011) — the correction path reuses the same splice as any other suggestion, so it must speak the same grammar. Locale-invariant throughout (ADR-013).
Examples
iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active])
...>
...> Flicker.Correction.to_token(facet, %Flicker.Correction{
...> value: :active,
...> label: "Active",
...> score: 1.0
...> })
"status:active "
iex> facet = Flicker.Facet.new(key: :price, type: :integer)
...> Flicker.Correction.to_token(facet, {:clamp, 500})
"price:500 "
iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
...> range = %Flicker.Facet.Range{from: ~D[2026-07-01], to: ~D[2026-07-30]}
...> Flicker.Correction.to_token(facet, {:swap, range})
"created:2026-07-01..2026-07-30 "