Flicker.Correction (Flicker v0.1.0)

Copy Markdown View Source

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.

t()

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

fix()

@type fix() :: nil | {:swap, term()} | {:clamp, term()} | {:replace, term()}

A mechanical fix with a known-correct answer, as opposed to a guess.

t()

@type t() :: %Flicker.Correction{label: String.t(), score: float(), value: term()}

A suggested replacement value.

Functions

candidates(facet, raw, reason, params \\ %{}, opts \\ [])

@spec candidates(Flicker.Facet.t(), String.t(), atom(), map(), keyword()) :: [t()]

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 (default 3).
  • :floor — minimum similarity, 0.0..1.0 (default 0.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"})
[]

explain(facet, reason, params \\ %{}, opts \\ [])

@spec explain(Flicker.Facet.t(), atom(), map(), keyword()) :: %{
  message: String.t(),
  fix: fix()
}

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

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}

to_token(facet, arg2)

@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 "