feat(awards): refuse an ambiguous match on one-reference awards

Two DARC DOKs are both called "Gießen" — two clubs share the town. Exact
matching finds both and both are right as far as the matcher can tell, so
it kept both and the operator got a QSO credited to a DOK that may well
have been the other one.

Picking one is not an option: it writes a reference into the log with no
evidence behind it. So a Def can now be marked one reference per QSO, and
an ambiguous match on such an award assigns none, names the candidates in
the trace and leaves the contact under missing references — where the
operator's choice now sticks (06e3437).

Off by default, and deliberately NOT a revival of the old `Multi` flag: a
field holding several references (an n-fer POTA activation, a VUCC grid
line) is several references, correctly, and stays that way.
This commit is contained in:
2026-08-01 01:04:17 +02:00
parent 06e34372ed
commit a4623e9ea3
6 changed files with 95 additions and 13 deletions
+29 -5
View File
@@ -75,10 +75,22 @@ type Def struct {
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
//
// This is not the old `Multi` flag, which was removed for good reason: a QSO
// always yields every reference its FIELD holds — an n-fer POTA activation
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
// references, correctly.
//
// The case here is the opposite. The field holds ONE value and two entries in
// the reference list carry it: two German DOKs are both called "Gießen",
// because two clubs share the town. The matcher is not wrong, the data is
// ambiguous — and picking one would write a reference into the log that may
// well be the other. So on an award where a QSO can only ever count for one
// reference, an ambiguous match assigns NONE and the contact appears under
// "missing references", where the operator picks. Their choice then sticks:
// a manual reference overrides the matcher.
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
// OrRules are ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
@@ -863,7 +875,11 @@ type Explanation struct {
// replaced it — shown in the trace so a correction is explainable rather
// than mysterious.
Superseded []string `json:"superseded,omitempty"`
Result []string `json:"result"` // what the QSO finally counts for
// Ambiguous lists the references that matched when the award allows only one
// — none of them was kept. Shown so the operator sees WHICH ones to choose
// between rather than an unexplained blank.
Ambiguous []string `json:"ambiguous,omitempty"`
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
@@ -998,6 +1014,14 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
found = manual
}
out := dedupe(found)
// Ambiguity is not a result. See OneRefPerQSO.
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
if ex != nil {
ex.Ambiguous = out
ex.Result = []string{}
}
return nil
}
if ex != nil {
ex.Result = out
}