-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patherrors.go
More file actions
454 lines (436 loc) · 20.9 KB
/
errors.go
File metadata and controls
454 lines (436 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
// Copyright 2026 AxonOps Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package-level sentinel errors for fuzzymatch. All errors are wrappable
// via fmt.Errorf("...: %w", ErrX) and discoverable by errors.Is /
// errors.As — never via string matching. See docs/requirements.md §6
// (canonical sentinel set) and §6.A (data-vs-parameter validation
// framework + panic policy).
//
// Error message convention (per .claude/skills/go-coding-standards):
//
// - Every message starts with the "fuzzymatch: " package prefix so
// wrappers like fmt.Errorf("scorer: %w", err) produce readable
// compositions ("scorer: fuzzymatch: invalid algorithm identifier").
// - The text after the prefix is lowercase and carries no trailing
// punctuation ('.', '!', or '?') so concatenation flows cleanly.
// - Each sentinel is a flat errors.New value, not a typed struct;
// richer per-item context can be added in a later phase if scan
// or extract needs it.
//
// The Phase 8.5 v1.0 sentinel contract (per docs/requirements.md §6):
// ErrInvalidAlgoID, ErrInvalidInnerAlgo, ErrInvalidQGramSize,
// ErrInvalidTverskyParam, ErrEmptyScorer, ErrInvalidWeight,
// ErrInvalidThreshold, ErrMissingThreshold, ErrInternalInvariantViolated.
//
// Phase 10 extends the canonical set with the Extract / ExtractOne
// surface sentinels (per docs/requirements.md §13 — added in plan
// 10-01): ErrMissingScorer, ErrConflictingScorer, ErrInvalidCutoff,
// ErrInvalidLimit, ErrInvalidChoice. Each follows the four-section
// Phase 8.5 Q4 godoc template (What / Common causes / Resolution /
// Example errors.Is snippet). The Phase 10 Extract validation surface
// is documented in 10-CONTEXT.md §1 D-04 + §2 D-12 + §3 D-13 + §3
// D-14.
package fuzzymatch
import "errors"
// ErrInvalidQGramSize indicates a q-gram-based algorithm option was
// constructed with n < 1 — q-gram extraction requires a positive window
// length and the formulas (Jaccard, Dice, Cosine, Tversky) are undefined
// for empty q-gram sets produced by a sub-unit window.
//
// Common causes: passing n = 0 to WithQGramJaccardAlgorithm /
// WithSorensenDiceAlgorithm / WithCosineAlgorithm / WithTverskyAlgorithm
// in the mistaken belief that n = 0 selects a sensible default; passing
// a computed window size that the caller failed to clamp to ≥ 1.
//
// Resolution: pass n ≥ 1. The dispatch default for the q-gram tier is
// n = 3 (the trigram convention from Ukkonen 1992). Direct algorithm
// calls (QGramJaccardScore, SorensenDiceScore, CosineScore, TverskyScore)
// panic with a value wrapping this sentinel — per docs/requirements.md
// §6.A direct calls fail loudly on programmer error and the Scorer
// option layer returns the typed error.
//
// Example:
//
// _, err := fuzzymatch.NewScorer(
// fuzzymatch.WithQGramJaccardAlgorithm(1.0, 3),
// fuzzymatch.WithThreshold(0.85),
// )
// if errors.Is(err, fuzzymatch.ErrInvalidQGramSize) {
// // diagnostic
// }
var ErrInvalidQGramSize = errors.New("fuzzymatch: invalid q-gram size")
// ErrInvalidTverskyParam indicates a Tversky algorithm option was
// constructed with an invalid α/β parameter pair: α < 0, β < 0, α + β
// ≤ 0, or any NaN/Inf value. The Tversky formula requires non-negative
// weights with at least one strictly positive so the denominator does
// not collapse to zero.
//
// Common causes: passing α = β = 0 in the mistaken belief that a zero
// pair selects a sensible default; passing a NaN computed by an upstream
// arithmetic step; passing a negative tuning parameter inadvertently.
//
// Resolution: pass α ≥ 0 and β ≥ 0 with α + β > 0; the canonical
// Jaccard-equivalent is α = β = 1.0 and the prototype-matching default
// is α = 1.0, β = 0.0. Direct calls to TverskyScore / TverskyScoreRunes
// panic with a value wrapping this sentinel on the same inputs.
//
// Example:
//
// _, err := fuzzymatch.NewScorer(
// fuzzymatch.WithTverskyAlgorithm(1.0, 1.0, 1.0, 3),
// fuzzymatch.WithThreshold(0.85),
// )
// if errors.Is(err, fuzzymatch.ErrInvalidTverskyParam) {
// // diagnostic
// }
var ErrInvalidTverskyParam = errors.New("fuzzymatch: invalid tversky parameter")
// ErrInvalidAlgoID indicates an AlgoID parameter does not match any
// registered algorithm in the dispatch table. Returned by the Phase 8
// Scorer option layer (WithAlgorithm, WithMongeElkanAlgorithm, …) when
// called with an out-of-range AlgoID (e.g. AlgoID(999)), a negative
// AlgoID, or a catalogue AlgoID whose dispatch entry has not yet been
// populated.
//
// Common causes: casting an int from external configuration to AlgoID
// without bounds-checking; using a constant from a future minor release
// that the running version does not yet ship; passing AlgoMongeElkan
// to WithMongeElkanAlgorithm (trivial-recursion guard — use
// WithAlgorithm with a different inner instead).
//
// Resolution: call AlgoIDs() to discover the valid set rather than
// guessing; for Monge-Elkan inner metrics, prefer one of the 18
// permitted inner AlgoIDs documented in monge_elkan.go. Direct calls
// to MongeElkanScore (symmetric default) / MongeElkanScoreAsymmetric
// (directional) panic with a value wrapping ErrInvalidInnerAlgo (the
// inner-specific sentinel) on a non-permitted inner.
//
// Example:
//
// _, err := fuzzymatch.NewScorer(
// fuzzymatch.WithAlgorithm(fuzzymatch.AlgoLevenshtein, 1.0),
// fuzzymatch.WithThreshold(0.85),
// )
// if errors.Is(err, fuzzymatch.ErrInvalidAlgoID) {
// // diagnostic
// }
var ErrInvalidAlgoID = errors.New("fuzzymatch: invalid algorithm identifier")
// ErrInvalidInnerAlgo indicates a Monge-Elkan composite was constructed
// with an inner AlgoID outside the 18-entry permitted set documented in
// monge_elkan.go. The Monge-Elkan formula composes a per-token inner
// similarity; the inner metric must itself be a per-pair character-,
// q-gram-, gestalt-, or phonetic-tier algorithm — token-tier algorithms
// (TokenSortRatio, TokenSetRatio, PartialRatio, TokenJaccard) and the
// MongeElkan self-reference are rejected.
//
// Common causes: passing AlgoMongeElkan to WithMongeElkanAlgorithm
// (self-recursion); passing a token-tier AlgoID as the inner metric
// in the mistaken belief that ME composes arbitrary algorithms.
//
// Resolution: pick one of the 18 permitted inner AlgoIDs (9 character-
// tier + 4 q-gram tier + 1 gestalt + 4 phonetic-tier). The default
// inner is AlgoJaroWinkler — pass that via WithAlgorithm(AlgoMongeElkan,
// w) for the typical case. Direct MongeElkanScore (symmetric default)
// / MongeElkanScoreAsymmetric (directional) calls with a non-permitted
// inner panic with a value wrapping this sentinel.
//
// Example:
//
// defer func() {
// if r := recover(); r != nil {
// if err, ok := r.(error); ok && errors.Is(err, fuzzymatch.ErrInvalidInnerAlgo) {
// // programmer error — pick a permitted inner
// }
// }
// }()
// _ = fuzzymatch.MongeElkanScore("a b", "c d", fuzzymatch.AlgoMongeElkan)
var ErrInvalidInnerAlgo = errors.New("fuzzymatch: invalid inner algorithm for Monge-Elkan composite")
// ErrEmptyScorer indicates NewScorer was called without any algorithm
// option — the option slice contained zero WithAlgorithm /
// With*Algorithm entries by the time validation ran. A Scorer with no
// algorithms has no meaningful composite to compute.
//
// Pass at least one WithAlgorithm option (or use DefaultScorer() for
// the opinionated six-algorithm composition).
//
// Returned by NewScorer (Phase 8) after the missing-threshold check
// passes and the option-validation pipeline finds cfg.entries empty.
//
// Discriminate via errors.Is(err, fuzzymatch.ErrEmptyScorer); never
// match the error message string.
var ErrEmptyScorer = errors.New("fuzzymatch: scorer has no algorithms (pass at least one WithAlgorithm option or use DefaultScorer)")
// ErrInvalidWeight indicates an algorithm weight passed to a Phase 8
// Scorer With*Algorithm option was ≤ 0. Weights must be strictly
// positive so that the auto-normalisation step (sum-to-1) has a
// positive divisor and the composite score remains in [0.0, 1.0].
//
// Returned by every Phase 8 With*Algorithm option at
// option-application time when the supplied weight fails the
// strict-positive constraint.
//
// Discriminate via errors.Is(err, fuzzymatch.ErrInvalidWeight); never
// match the error message string.
var ErrInvalidWeight = errors.New("fuzzymatch: invalid algorithm weight (must be > 0)")
// ErrInvalidThreshold indicates a WithThreshold value was outside the
// closed interval [0.0, 1.0]. Thresholds are compared against composite
// scores which the Scorer guarantees fall in [0.0, 1.0] under default
// weight normalisation; values outside the interval are non-sensical.
//
// Returned by WithThreshold at option-application time and surfaced
// through NewScorer.
//
// Discriminate via errors.Is(err, fuzzymatch.ErrInvalidThreshold);
// never match the error message string.
var ErrInvalidThreshold = errors.New("fuzzymatch: invalid threshold (must be in [0.0, 1.0])")
// ErrMissingThreshold indicates NewScorer was called without
// WithThreshold. The threshold is a calibration parameter with no
// universally-safe default, so the library refuses to guess. Pass
// WithThreshold(t) with t ∈ [0.0, 1.0] during construction, or use
// DefaultScorer() for the opinionated default composition that bakes
// 0.85 in.
//
// Returned by NewScorer (Phase 8) when no WithThreshold option is
// present in the variadic opts slice. This check fires FIRST in the
// validation pipeline so the error is unambiguous when a user forgets
// the threshold alongside another option problem.
//
// Discriminate via errors.Is(err, fuzzymatch.ErrMissingThreshold);
// never match the error message string.
var ErrMissingThreshold = errors.New("fuzzymatch: scorer threshold required (pass WithThreshold or use DefaultScorer)")
// ErrInternalInvariantViolated indicates a library invariant that
// should never fire in correct usage was violated — seeing it as a
// recovered panic value is unambiguous evidence of a library bug.
//
// Common causes: NEVER caller-supplied input. The single current panic
// site is DefaultScorer() when its baked-in option pipeline fails
// validation (which would indicate the locked default composition has
// drifted out of sync with NewScorer's validation pipeline). Future
// "this should be impossible" guards may wrap the same sentinel.
//
// Resolution: file a bug report against
// https://github.com/axonops/fuzzymatch with the panic stack trace
// and a minimal reproducer. This sentinel MUST NOT be used to wrap
// caller-supplied parameter errors; parameter errors use the dedicated
// parameter sentinels (ErrInvalidAlgoID, ErrInvalidWeight, etc.).
//
// Example:
//
// defer func() {
// if r := recover(); r != nil {
// if err, ok := r.(error); ok && errors.Is(err, fuzzymatch.ErrInternalInvariantViolated) {
// // library bug — collect stack + file issue
// }
// }
// }()
// s := fuzzymatch.DefaultScorer()
// _ = s.Score("a", "b")
var ErrInternalInvariantViolated = errors.New("fuzzymatch: internal invariant violated (library bug — please file an issue)")
// ErrInvalidSWGParam indicates a Smith-Waterman-Gotoh parameter struct
// (SWGParams) violates the documented sign-and-finite invariants:
// Match must be a finite, non-negative float; Mismatch, GapOpen, and
// GapExtend must be finite, non-positive floats; NaN and ±Inf are
// rejected in every field. The Tversky-style strict-parameter framework
// (docs/requirements.md §6.A) applies — invalid construction is a
// programmer error and panics with a typed-error value wrapping this
// sentinel so consumers can discriminate via errors.Is on a recovered
// panic value.
//
// Common causes: copying a partially-initialised SWGParams across a
// boundary; computing GapOpen / GapExtend from upstream arithmetic that
// produced NaN or Inf; flipping Match negative by accident; mutating
// the SWGParams returned by NewSWGParams and forgetting to call
// Validate() before passing it to SmithWatermanGotohScoreWithParams.
//
// Resolution: pass params satisfying Match ≥ 0, Mismatch ≤ 0,
// GapOpen ≤ 0, GapExtend ≤ 0, all finite. The canonical defaults from
// NewSWGParams (Match=1.0, Mismatch=-1.0, GapOpen=-1.5, GapExtend=-0.5)
// always pass this gate; consumers mutating the struct after
// construction should call params.Validate() to re-assert before use.
//
// Example:
//
// defer func() {
// if r := recover(); r != nil {
// if err, ok := r.(error); ok && errors.Is(err, fuzzymatch.ErrInvalidSWGParam) {
// // programmer error — log and re-panic, or substitute defaults
// }
// }
// }()
// params := fuzzymatch.NewSWGParams()
// params.Match = math.NaN() // consumer error
// params.Validate() // panics with a typed error wrapping ErrInvalidSWGParam
var ErrInvalidSWGParam = errors.New("fuzzymatch: invalid Smith-Waterman-Gotoh parameters")
// ErrInvalidCutoff indicates a WithCutoff option was constructed with
// a value outside [0.0, 1.0], or with NaN / ±Inf. The cutoff is
// Extract's emission filter; the value must be finite and in the same
// range as a composite Scorer.Score result. Phase 10 D-12
// parameter-strict guard per the Phase 8.5 Q2 framework — mirrors
// ErrInvalidThreshold's strict shape.
//
// Common causes: passing a percentage value (e.g. 85.0) in the
// mistaken belief that the cutoff is in [0, 100]; passing a NaN
// computed by an upstream arithmetic step.
//
// Resolution: pass a value in [0.0, 1.0] (e.g. 0.85). For the
// contextual default behaviour — falling back to the Scorer's
// threshold or 0.0 depending on whether WithScorer or WithAlgorithmID is set
// — omit WithCutoff entirely per Phase 10 D-11; see Extract godoc for
// the contextual default rule. Discriminate via
// errors.Is(err, fuzzymatch.ErrInvalidCutoff); never match the error
// message string.
//
// Example:
//
// _, err := fuzzymatch.Extract(
// "query",
// []fuzzymatch.Choice{{Name: "candidate"}},
// fuzzymatch.WithScorer(fuzzymatch.DefaultScorer()),
// fuzzymatch.WithCutoff(85.0), // INVALID — percentages must be in [0.0, 1.0]
// )
// if errors.Is(err, fuzzymatch.ErrInvalidCutoff) {
// // pass 0.85, not 85.0
// }
var ErrInvalidCutoff = errors.New("fuzzymatch: invalid cutoff (must be finite, in [0.0, 1.0])")
// ErrInvalidLimit indicates a WithLimit option was constructed with a
// negative value. WithLimit caps the number of returned Hits; 0 means
// unlimited (return everything passing the cutoff, sorted score-desc)
// and positive integers cap at that count. Negative values are
// non-sensical. Phase 10 D-13 parameter-strict guard.
//
// Common causes: passing a negative value (typo or off-by-one);
// passing the result of a computed expression without clamping (e.g.
// `WithLimit(userRequested - 1)` where userRequested could be 0).
//
// Resolution: pass 0 for unlimited (the default behaviour — equivalent
// to omitting WithLimit entirely) or a positive integer for the cap.
// WithLimit(N) where N exceeds the cutoff-passing match count returns
// all matches without error and without padding — over-specifying the
// limit is harmless. Discriminate via
// errors.Is(err, fuzzymatch.ErrInvalidLimit); never match the error
// message string.
//
// Example:
//
// _, err := fuzzymatch.Extract(
// "query",
// []fuzzymatch.Choice{{Name: "candidate"}},
// fuzzymatch.WithScorer(fuzzymatch.DefaultScorer()),
// fuzzymatch.WithLimit(-1), // INVALID — must be >= 0
// )
// if errors.Is(err, fuzzymatch.ErrInvalidLimit) {
// // pass 0 for unlimited or a positive cap
// }
var ErrInvalidLimit = errors.New("fuzzymatch: invalid limit (must be non-negative; 0 means unlimited)")
// ErrInvalidChoice indicates Extract or ExtractOne received one or
// more Choice entries with an empty Name. The choices slice is the
// consumer's curated corpus; empty entries are almost certainly bugs
// in the consumer's corpus construction. Phase 10 D-14 corpus-strict
// guard.
//
// The validation runs once at Extract entry, ahead of the scoring
// loop, and collects EVERY offending index via errors.Join (Go 1.20+
// Unwrap() []error walking). One Extract call with three empty-Name
// Choices produces a single joined error wrapping all three indices
// — errors.Is(joined, ErrInvalidChoice) discriminates the sentinel
// across the whole chain.
//
// Common causes: composing a corpus from a database column that has
// nullable values without a filter step; concatenating choice slices
// from multiple sources without sanitising for empty strings; relying
// on a defensive `if name != "" { ... }` upstream that did not in
// fact fire for every input row.
//
// Resolution: filter the choices slice for empty Name before calling
// Extract, OR receive the joined error and use errors.Is /
// errors.As to extract the offending indices for upstream diagnostics.
// Choice.Tag is never stringified into the error chain (security
// guarantee per Choice.Tag godoc) — only the int index appears.
// Discriminate via errors.Is(err, fuzzymatch.ErrInvalidChoice); never
// match the error message string.
//
// Example:
//
// _, err := fuzzymatch.Extract(
// "query",
// []fuzzymatch.Choice{
// {Name: "valid"},
// {Name: ""}, // INVALID — empty Name
// },
// fuzzymatch.WithScorer(fuzzymatch.DefaultScorer()),
// )
// if errors.Is(err, fuzzymatch.ErrInvalidChoice) {
// // err wraps every offending index via errors.Join — Go 1.20+
// // errors.Is walks Unwrap() []error so this discrimination
// // succeeds even when multiple choices were invalid.
// }
var ErrInvalidChoice = errors.New("fuzzymatch: invalid choice (empty Name)")
// ErrMissingScorer indicates Extract or ExtractOne was called without
// either of the required scoring options — WithScorer(*Scorer) or
// WithAlgorithmID(AlgoID). Exactly one is required per Phase 10 D-04 XOR
// rule. Extract is parameterised on the consumer's chosen scorer;
// there is no implicit default Scorer (consumers needing the
// opinionated default explicitly pass WithScorer(DefaultScorer())).
//
// Common causes: forgetting to pass a scoring option entirely;
// constructing the variadic options slice with the scoring option
// commented out during a refactor; assuming a default Scorer is
// implicit (it is not — by design, per the symmetry with
// NewScorer's required WithThreshold).
//
// Resolution: pass WithScorer(fuzzymatch.DefaultScorer()) for the
// opinionated default (six-algorithm composition baked in), or
// WithAlgorithmID(fuzzymatch.AlgoLevenshtein) for single-algorithm mode.
// Cutoff defaults contextually based on which is set (D-11).
// Discriminate via errors.Is(err, fuzzymatch.ErrMissingScorer); never
// match the error message string.
//
// Example:
//
// _, err := fuzzymatch.Extract(
// "query",
// []fuzzymatch.Choice{{Name: "candidate"}},
// // INVALID — no WithScorer, no WithAlgorithmID
// )
// if errors.Is(err, fuzzymatch.ErrMissingScorer) {
// // pass WithScorer(DefaultScorer()) or WithAlgorithmID(AlgoLevenshtein)
// }
var ErrMissingScorer = errors.New("fuzzymatch: missing scoring option (pass WithScorer or WithAlgorithmID to Extract)")
// ErrConflictingScorer indicates Extract or ExtractOne received both
// scoring options — WithScorer(*Scorer) and WithAlgorithmID(AlgoID) — in the
// same call. Exactly one is required per Phase 10 D-04 XOR rule;
// neither and both are equally invalid.
//
// Common causes: composing options from multiple call sites that each
// pass a scoring option; a builder pattern that adds WithAlgorithmID over a
// base option set that already includes WithScorer; copy-paste
// pasting two scoring-mode examples together.
//
// Resolution: choose one scoring path per Extract call. If a
// composition pipeline genuinely needs both single-algo and composite
// modes, branch on the consumer's intent at the call site and pass
// the appropriate option to each branch. Discriminate via
// errors.Is(err, fuzzymatch.ErrConflictingScorer); never match the
// error message string.
//
// Example:
//
// _, err := fuzzymatch.Extract(
// "query",
// []fuzzymatch.Choice{{Name: "candidate"}},
// fuzzymatch.WithScorer(fuzzymatch.DefaultScorer()),
// fuzzymatch.WithAlgorithmID(fuzzymatch.AlgoLevenshtein), // INVALID — conflict
// )
// if errors.Is(err, fuzzymatch.ErrConflictingScorer) {
// // pick one scoring path per Extract call
// }
var ErrConflictingScorer = errors.New("fuzzymatch: conflicting scoring options (Extract accepts WithScorer or WithAlgorithmID but not both)")