@@ -71,6 +71,14 @@ module TestArgParserPositionalReference =
7171
7272 /// A structural input: which named leaves were observed, and the positional events in
7373 /// order. (Order does not affect acceptance; it is the candidate sets that matter.)
74+ ///
75+ /// This is * post-scanning* input, and the split into named observations and positional
76+ /// events is well defined only under the existing restriction that a positional sink may
77+ /// sit beside a union only in Reject mode: a Collect-mode sink turns an unrecognised
78+ /// ` --key ` token into a positional event, so the split would itself depend on which case
79+ /// the scan is trying to select. Deliberately unmodelled, therefore: unrecognised
80+ /// flag-like tokens, ` --help ` , scanner errors, and repeat occurrences of a single named
81+ /// leaf (this is a set, so the runtime's duplicate-argument error is out of scope).
7482 type RefInput =
7583 {
7684 ObservedNamed : Set < int >
@@ -174,6 +182,14 @@ module TestArgParserPositionalReference =
174182 |> List.map ( fun p -> p.Id)
175183 |> Set.ofList
176184
185+ /// The named half of acceptance: every observed named leaf belongs to the interpretation,
186+ /// and every required named leaf of the interpretation was observed. Named separately
187+ /// because on a well-formed tree this half already selects: see the confirm-or-veto
188+ /// property below.
189+ let acceptsNamed ( interp : Interpretation ) ( input : RefInput ) : bool =
190+ Set.isSubset input.ObservedNamed interp.Named
191+ && Set.isSubset interp.RequiredNamed input.ObservedNamed
192+
177193 /// The acceptance predicate: does this interpretation structurally accept this input?
178194 ///
179195 /// - every observed named leaf belongs to the interpretation;
@@ -183,8 +199,7 @@ module TestArgParserPositionalReference =
183199 ///
184200 /// Conversion is deliberately absent: whether values parse never features.
185201 let accepts ( sinks : RefPositionalLeaf list ) ( interp : Interpretation ) ( input : RefInput ) : bool =
186- Set.isSubset input.ObservedNamed interp.Named
187- && Set.isSubset interp.RequiredNamed input.ObservedNamed
202+ acceptsNamed interp input
188203 && ( List.isEmpty input.PositionalEvents
189204 || (
190205 match Set.toList interp.Positionals with
@@ -197,7 +212,18 @@ module TestArgParserPositionalReference =
197212
198213 /// The reference semantics: the parse is structurally successful exactly when one
199214 /// interpretation accepts.
215+ ///
216+ /// The tree must satisfy linearity (capacity at most one positional leaf). That is a
217+ /// property of the schema, not of the input, so it is checked up front: the assertion
218+ /// inside ` accepts ` fires only once an event needs routing, which would silently accept an
219+ /// over-capacity tree on the inputs that happen to carry no positional events at all.
200220 let exhaustiveSelect ( tree : RefTree ) ( input : RefInput ) : RefOutcome =
221+ match maxPositionals tree with
222+ | 0
223+ | 1 -> ()
224+ | capacity ->
225+ failwithf " linearity violated: this tree admits an interpretation with %i positional leaves" capacity
226+
201227 let sinks = positionalLeaves tree
202228
203229 match interpretations tree |> List.filter ( fun interp -> accepts sinks interp input) with
@@ -647,28 +673,36 @@ module TestArgParserPositionalReference =
647673 acceptCount |> shouldBeGreaterThan 200
648674
649675 [<Test>]
650- let ``Bare - only inputs are never ambiguous when every sum passes the empty - ambiguity check`` () =
651- // The lemma which makes the generation-time check sufficient: bare tokens name every
652- // sink, so they cannot discriminate between cases; if two interpretations both
653- // accepted a bare-only input, both their cases (at the outermost sum where they
654- // differ) would have to be satisfiable with no named observations — exactly what the
655- // per-sum check forbids. Keyed events are excluded: a keyed form genuinely can
656- // discriminate, which is by design.
676+ let ``Positional events can only confirm or veto the interpretation the named half selects`` () =
677+ // The lemma which makes the generation-time check sufficient, in its strongest form:
678+ // the *named* half of acceptance already picks out at most one interpretation. Leaf
679+ // ids are distinct across cases, so an observed leaf belongs to exactly one of them;
680+ // and if two interpretations were both named-accepted, then at the outermost sum where
681+ // they differ both their cases would have to be satisfiable with no named observations
682+ // — exactly what the per-sum check forbids.
683+ //
684+ // Acceptance is a conjunction, so the positional half can only filter that singleton.
685+ // Hence no input is ever ambiguous, and no positional event — bare *or* keyed — can
686+ // change which case is chosen. A bare token names every sink and so is always neutral;
687+ // a keyed token naming no sink of the selected interpretation vetoes it outright
688+ // rather than selecting some other case.
689+ //
690+ // This is what licenses the production resolver to select structurally first and
691+ // validate the events against the active sink afterwards. It rests on ids being
692+ // distinct across cases, which the generator enforces by rejecting an argument name
693+ // shared between two union cases; were that relaxed, a keyed form genuinely could
694+ // select, and this property would fail.
657695 let mutable checkedTrees = 0
658696 let mutable inputsWithEvents = 0
697+ let mutable inputsWithKeyedEvents = 0
698+ let mutable vetoed = 0
659699
660700 let cases =
661701 gen {
662702 let! sumBias = Gen.elements [ 30 ; 60 ; 90 ]
663703 let! tree = genTree sumBias
664- let! rawInput = genInput 30 40 20 tree
665- let bareOnly = rawInput.PositionalEvents |> List.map ( fun _ -> RefSpelling.Bare)
666-
667- return
668- tree,
669- { rawInput with
670- PositionalEvents = bareOnly
671- }
704+ let! input = genInput 30 40 20 tree
705+ return tree, input
672706 }
673707
674708 let property ( tree : RefTree , input : RefInput ) : unit =
@@ -678,16 +712,42 @@ module TestArgParserPositionalReference =
678712 if not ( List.isEmpty input.PositionalEvents) then
679713 inputsWithEvents <- inputsWithEvents + 1
680714
715+ let isKeyed ( spelling : RefSpelling ) : bool =
716+ match spelling with
717+ | RefSpelling.Keyed _ -> true
718+ | RefSpelling.Bare -> false
719+
720+ if input.PositionalEvents |> List.exists isKeyed then
721+ inputsWithKeyedEvents <- inputsWithKeyedEvents + 1
722+
723+ let namedOnly =
724+ interpretations tree |> List.filter ( fun interp -> acceptsNamed interp input)
725+
726+ // The named half alone already selects.
727+ List.length namedOnly |> shouldBeSmallerThan 2
728+
681729 match exhaustiveSelect tree input with
682730 | RefOutcome.Ambiguous _ -> failwithf " ambiguous outcome for %A on %A " tree input
683- | RefOutcome.Unique _
684- | RefOutcome.NoInterpretation -> ()
731+ | RefOutcome.Unique interp ->
732+ // Confirmation: the events agreed, and the case chosen is the one the
733+ // named half had already picked.
734+ match namedOnly with
735+ | [ only ] -> interp.Choices |> shouldEqual only.Choices
736+ | _ -> failwithf " accepted %A , which the named half did not select, on %A " interp input
737+ | RefOutcome.NoInterpretation ->
738+ // Veto (or nothing was named-accepted in the first place).
739+ if not ( List.isEmpty namedOnly) then
740+ vetoed <- vetoed + 1
685741
686742 let config = Config.QuickThrowOnFailure.WithMaxTest 2000
687743 Check.One ( config, Prop.forAll ( Arb.fromGen cases) property)
688744
689745 checkedTrees |> shouldBeGreaterThan 500
690746 inputsWithEvents |> shouldBeGreaterThan 200
747+ // The keyed half of the law is the interesting one, and vetoes must actually happen:
748+ // otherwise "confirm or veto" is only being tested on inputs which confirm.
749+ inputsWithKeyedEvents |> shouldBeGreaterThan 100
750+ vetoed |> shouldBeGreaterThan 20
691751
692752 [<Test>]
693753 let ``The generator explores every outcome regime`` () =
@@ -815,7 +875,7 @@ module TestArgParserPositionalReference =
815875 |> shouldEqual RefOutcome.NoInterpretation
816876
817877 [<Test>]
818- let ``Per - case sinks : a distinctive keyed form is itself a structural discriminator `` () =
878+ let ``Per - case sinks : a distinctive keyed form confirms or vetoes , but never selects `` () =
819879 let tree =
820880 RefTree.Sum (
821881 0 ,
@@ -825,14 +885,32 @@ module TestArgParserPositionalReference =
825885 ]
826886 )
827887
828- // Nothing but --files=x: only Foo's interpretation can consume the event.
888+ // Foo is what the named half selects on no observations at all: it is empty-satisfiable
889+ // and Bar, whose named leaf is required, is not.
890+ match exhaustiveSelect tree ( input [] []) with
891+ | RefOutcome.Unique interp -> interp.Choices |> shouldEqual ( Map.ofList [ 0 , 0 ])
892+ | other -> failwithf " unexpected outcome: %A " other
893+
894+ // --files=x therefore *confirms* that choice rather than making it: the sink Foo
895+ // already carries is the one which claims the form, so the event routes and the
896+ // outcome is unchanged.
829897 match exhaustiveSelect tree ( input [] [ RefSpelling.Keyed " files" ]) with
830898 | RefOutcome.Unique interp ->
831899 interp.Choices |> shouldEqual ( Map.ofList [ 0 , 0 ])
832900 interp.Positionals |> shouldEqual ( Set.singleton 1000 )
833901 | other -> failwithf " unexpected outcome: %A " other
834902
835- // A keyed form belonging to the case the named observations exclude: no acceptance.
903+ // --nums=x names no sink of Foo, so it vetoes: the outcome is failure, and emphatically
904+ // not a switch to Bar, whose required named leaf was never observed.
905+ exhaustiveSelect tree ( input [] [ RefSpelling.Keyed " nums" ])
906+ |> shouldEqual RefOutcome.NoInterpretation
907+
908+ // Likewise where the named observations select Bar: --files belongs to the case they
909+ // exclude, so it vetoes rather than dragging the choice back to Foo.
910+ match exhaustiveSelect tree ( input [ 1 ] []) with
911+ | RefOutcome.Unique interp -> interp.Choices |> shouldEqual ( Map.ofList [ 0 , 1 ])
912+ | other -> failwithf " unexpected outcome: %A " other
913+
836914 exhaustiveSelect tree ( input [ 1 ] [ RefSpelling.Keyed " files" ])
837915 |> shouldEqual RefOutcome.NoInterpretation
838916
@@ -855,7 +933,18 @@ module TestArgParserPositionalReference =
855933 // The linearity rule: argv has one positional stream, so P × Q is rejected by
856934 // capacity while (A × P) + (B × Q) is fine. This is what schema validation will
857935 // enforce; the model just states the arithmetic.
858- maxPositionals ( RefTree.Product [ sink 1000 [ " rest" ] ; sink 1001 [ " files" ] ])
859- |> shouldEqual 2
936+ let overCapacity = RefTree.Product [ sink 1000 [ " rest" ] ; sink 1001 [ " files" ] ]
937+
938+ maxPositionals overCapacity |> shouldEqual 2
860939
861940 maxPositionals perCaseSinks |> shouldEqual 1
941+
942+ // The model refuses to interpret such a tree at all. In particular it refuses on an
943+ // input with no positional events, which routes nothing and so would otherwise sail
944+ // past the routing-time check and report a unique interpretation holding both sinks.
945+ for events in [ [] ; [ RefSpelling.Bare ] ; [ RefSpelling.Keyed " rest" ] ] do
946+ let exc =
947+ Assert.Throws< exn> ( fun () -> exhaustiveSelect overCapacity ( input [] events) |> ignore< RefOutcome>)
948+
949+ exc.Message
950+ |> shouldEqual " linearity violated: this tree admits an interpretation with 2 positional leaves"
0 commit comments