Skip to content

Commit f0e6394

Browse files
JanWielemakerclaude
andcommitted
TEST: Randomised property test for dif/2
Generate a random ground term T with library(random_terms), produce a generalisation G by replacing a random subset of subterms with fresh variables (recorded as `V=Value`), and optionally alias two of those variables so G also carries a sharing constraint. Post `dif(G, T)`, apply a random plan of matching and/or clash bindings, then require the observed outcome to match the prediction: - `fail` iff the plan covers every unique variable with matching values and no alias conflict — G is then structurally equal to T. - `ok` in every other case (a clash was applied, some variable is left free so dif stays delayed, or an alias already forces G to be structurally unequal to T). Because the expected outcome is decided by construction, a bug that would slip past a differential test (fast dif compared to a when/2 reference) is caught here. Mismatches are reported with the offending T, G and plan, ready to add to test_dif.pl as a regression case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 46b705c commit f0e6394

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

tests/attvar/test_dif_random.pl

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/* Part of SWI-Prolog
2+
3+
Author: Jan Wielemaker
4+
E-mail: jan@swi-prolog.org
5+
WWW: http://www.swi-prolog.org
6+
Copyright (c) 2026, SWI-Prolog Solutions b.v.
7+
All rights reserved.
8+
9+
Redistribution and use in source and binary forms, with or without
10+
modification, are permitted provided that the following conditions
11+
are met:
12+
13+
1. Redistributions of source code must retain the above copyright
14+
notice, this list of conditions and the following disclaimer.
15+
16+
2. Redistributions in binary form must reproduce the above copyright
17+
notice, this list of conditions and the following disclaimer in
18+
the documentation and/or other materials provided with the
19+
distribution.
20+
21+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24+
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25+
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26+
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27+
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30+
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31+
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32+
POSSIBILITY OF SUCH DAMAGE.
33+
*/
34+
35+
:- module(test_dif_random,
36+
[ test_dif_random/0,
37+
test_dif_random/1 % +Iterations
38+
]).
39+
:- use_module(library(dif)).
40+
:- use_module(library(random_terms)).
41+
:- use_module(library(random)).
42+
:- use_module(library(lists)).
43+
:- use_module(library(plunit)).
44+
45+
/** <module> Randomised property test for dif/2
46+
47+
Take a random ground term T. Produce a generalisation G by replacing
48+
some subterms of T with fresh variables (each replacement recorded as
49+
`Var=Value`, so that unifying all replacements makes G structurally
50+
equal to T). Some of those variables are then aliased so that G also
51+
carries sharing constraints. Post `dif(G, T)` and drive it with a
52+
randomly chosen mix of
53+
54+
- "match" bindings that push G towards T,
55+
- one "clash" binding that forces a mismatch, or
56+
- leaving some variables unbound.
57+
58+
The predicted outcome is decidable from the plan itself:
59+
60+
- dif(G, T) must fail iff the plan drives G to be structurally == T
61+
(all replacements applied with their matching values, and no
62+
alias-conflict).
63+
- dif(G, T) must succeed in every other case: either a clash was
64+
applied, or some replacement was left unbound (so dif stays
65+
delayed), or the alias forced two positions to hold values that
66+
are decidably unequal in T.
67+
68+
Any deviation is reported with the offending T, G, plan and observed
69+
result — a minimal regression case ready to add to test_dif.pl.
70+
*/
71+
72+
test_dif_random :-
73+
run_tests(dif_random).
74+
75+
test_dif_random(N) :-
76+
forall(between(1, N, _), check_scenario).
77+
78+
:- begin_tests(dif_random).
79+
80+
test(scenario, [forall(between(1, 500, _))]) :-
81+
check_scenario.
82+
83+
:- end_tests(dif_random).
84+
85+
check_scenario :-
86+
random_ground_term(T),
87+
generalize(T, G, Bindings0),
88+
maybe_alias_bindings(Bindings0, Bindings, AliasBlocksMatch),
89+
plan(Bindings, Plan, Expected0),
90+
( AliasBlocksMatch == true
91+
-> Expected = ok % G can never equal T ⇒ dif always holds
92+
; Expected = Expected0
93+
),
94+
run(G, T, Plan, Observed),
95+
( Observed == Expected
96+
-> true
97+
; report(scenario, T, G, Plan, Expected, Observed),
98+
fail
99+
).
100+
101+
random_ground_term(T) :-
102+
random_between(2, 5, Depth),
103+
random_between(1, 3, Arity),
104+
random_term(T,
105+
[ depth(Depth), max_arity(Arity),
106+
w_var(0), w_cycle(0)
107+
]).
108+
109+
%! generalize(+T, -G, -Bindings) is det.
110+
%
111+
% G is a fresh term structurally equal to T except that a random
112+
% subset of subterms has been replaced by fresh variables. Bindings
113+
% is the list `[Var=Value, ...]` such that applying every unification
114+
% makes G unifiable-with-and-then-equal-to T.
115+
116+
generalize(T, G, Bs) :-
117+
generalize_(T, G, Bs, []).
118+
119+
generalize_(T, G, Bs0, Bs) :-
120+
( maybe(0.3)
121+
-> G = _V,
122+
Bs0 = [G=T | Bs]
123+
; compound(T)
124+
-> compound_name_arguments(T, F, As),
125+
generalize_args(As, Gs, Bs0, Bs),
126+
compound_name_arguments(G, F, Gs)
127+
; G = T,
128+
Bs0 = Bs
129+
).
130+
131+
generalize_args([], [], Bs, Bs).
132+
generalize_args([A|As], [G|Gs], Bs0, Bs) :-
133+
generalize_(A, G, Bs0, Bs1),
134+
generalize_args(As, Gs, Bs1, Bs).
135+
136+
%! maybe_alias_bindings(+Bindings0, -Bindings, -AliasBlocksMatch) is det.
137+
%
138+
% Optionally alias two variables in Bindings0 so that G carries a
139+
% sharing constraint (the same var appears at two positions).
140+
% AliasBlocksMatch is `true` when two aliased positions carry
141+
% decidably different values in T — G can then never equal T, so
142+
% dif(G, T) is satisfied regardless of the plan.
143+
144+
maybe_alias_bindings(Bindings0, Bindings, AliasBlocksMatch) :-
145+
( Bindings0 = [V1=Val1 | Rest0],
146+
select(V2=Val2, Rest0, Rest),
147+
maybe(0.4)
148+
-> V1 = V2,
149+
Bindings = [V1=Val1 | Rest],
150+
( Val1 \== Val2, ground(Val1), ground(Val2)
151+
-> AliasBlocksMatch = true
152+
; AliasBlocksMatch = false
153+
)
154+
; Bindings = Bindings0,
155+
AliasBlocksMatch = false
156+
).
157+
158+
%! plan(+Bindings, -Plan, -Expected) is det.
159+
%
160+
% Pick a random subset of `Bindings` (in some order) as the plan.
161+
% Optionally swap exactly one of the picked bindings for a clash
162+
% value. Expected is decidable from the plan:
163+
%
164+
% * `fail` iff the plan is a full match sequence covering every
165+
% unique variable — that binds G structurally to T.
166+
% * `ok` in every other case.
167+
168+
plan([], [], fail) :- !. % G identical to T
169+
plan(Bindings, Plan, Expected) :-
170+
unique_vars(Bindings, UVs),
171+
length(UVs, U),
172+
random_permutation(Bindings, Shuffled),
173+
length(Shuffled, N),
174+
random_between(0, N, K),
175+
length(Prefix, K),
176+
append(Prefix, _Rest, Shuffled),
177+
plan_prefix_vars(Prefix, PrefixVs),
178+
length(PrefixVs, PU),
179+
( K > 0, maybe(0.5)
180+
-> random_between(1, K, Idx),
181+
nth1(Idx, Prefix, V=Val),
182+
clash_value(Val, Clash),
183+
nth1_replace(Idx, Prefix, V=Clash, Plan),
184+
Expected = ok
185+
; Plan = Prefix,
186+
( PU =:= U
187+
-> Expected = fail % covers all unique vars
188+
; Expected = ok
189+
)
190+
).
191+
192+
unique_vars(Bindings, UVs) :-
193+
findall(V, member(V=_, Bindings), Vs),
194+
sort(Vs, UVs).
195+
196+
plan_prefix_vars(Prefix, PVs) :-
197+
findall(V, member(V=_, Prefix), Vs),
198+
sort(Vs, PVs).
199+
200+
nth1_replace(1, [_|T], X, [X|T]) :- !.
201+
nth1_replace(N, [H|T], X, [H|R]) :-
202+
N1 is N - 1,
203+
nth1_replace(N1, T, X, R).
204+
205+
%! clash_value(+Val, -Clash) is det.
206+
%
207+
% Produce a value guaranteed not to unify with Val given the leaf
208+
% domains random_term uses (atoms `[a,b,c]`, ints in `-100..100`).
209+
210+
clash_value(Val, Clash) :-
211+
( integer(Val)
212+
-> Clash is Val + 10_000
213+
; atom(Val)
214+
-> atom_concat(clashed_, Val, Clash)
215+
; compound(Val)
216+
-> Clash = clashed_atom
217+
; Clash = clashed_atom
218+
).
219+
220+
%! run(+G, +T, +Plan, -Observed) is det.
221+
%
222+
% Post dif(G, T) and apply the plan. Observed is `ok` when the whole
223+
% sequence succeeded, `fail` when it failed, `error(E)` on exception.
224+
225+
run(G, T, Plan, Observed) :-
226+
catch(
227+
( ( dif(G, T), apply_plan(Plan)
228+
-> Observed = ok
229+
; Observed = fail
230+
)
231+
),
232+
Error,
233+
Observed = error(Error)
234+
).
235+
236+
apply_plan([]).
237+
apply_plan([V=X|T]) :-
238+
V = X,
239+
apply_plan(T).
240+
241+
report(Phase, T, G, Plan, Expected, Observed) :-
242+
copy_term(T-G-Plan, Tc-Gc-Pc),
243+
numbervars(Tc-Gc-Pc, 0, _, [singletons(false)]),
244+
format(user_error,
245+
"MISMATCH ~w: expected ~w, observed ~w~n T=~q~n G=~q~n Plan=~q~n",
246+
[Phase, Expected, Observed, Tc, Gc, Pc]).

0 commit comments

Comments
 (0)