Skip to content

Commit a0eb263

Browse files
committed
cleaned up tests
1 parent 916e844 commit a0eb263

5 files changed

Lines changed: 252 additions & 7 deletions

File tree

.github/workflows/ci-property-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@ jobs:
4848
timeout-minutes: 5
4949
run: |
5050
ctest -C ${BUILD_TYPE} --output-on-failure --parallel \
51-
-R '(quat|reproject|kabsch|kalman|numeric)_props'
51+
-R '(quat|reproject|kabsch|kalman|numeric|event_queue|residual_cascade|variance_gate|normal_filter|disambiguator)_props'

docs/property-tests.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,36 @@ Tests the back-facing normal filter in `SurviveSensorActivations_check_outlier()
161161

162162
At the default threshold 0.0, sensors within 90° of the lighthouse are accepted and sensors beyond 90° are rejected, which is the physically correct cutoff for a flat sensor surface.
163163

164+
### 9. Pulse-Timing Decode (`disambiguator_props.c`)
165+
166+
8 properties, up to 10,000 random trials each.
167+
168+
Tests the pure functions in `disambiguator_statebased.c` that decode a raw USB `LightcapElement`
169+
(sensor_id, pulse length, timestamp) into an acode classification and overlap/error metrics — the
170+
layer directly upstream of all sync/sweep state tracking. These four functions (`find_acode`,
171+
`overlap_area`, `overlaps`, `calculate_error`) were `static`; they were made non-static (visibility
172+
change only, no behavior change) so this suite can call them directly without a
173+
`SurviveContext`/`SurviveObject`. The deeper sync-timing state machine (`find_inliers`,
174+
`find_relative_offset`, and the `last_time_between_sync` arithmetic in `survive_process_gen2.c`
175+
where the `sync_timing_nan_guard` Stagehand patch lives) is not separable into pure functions
176+
without a refactor and is out of scope for this suite.
177+
178+
| Test | Property |
179+
|---|---|
180+
| `FindAcodeRange` | `find_acode()` always returns in `[-1, 7]` |
181+
| `FindAcodeMonotonic` | `find_acode()` is monotonic non-decreasing in pulse length |
182+
| `FindAcodeBucketCenters` | Each 500-tick bucket center maps to its documented acode; out-of-range pulse lengths return -1, never silently clamp |
183+
| `OverlapAreaSymmetric` | `overlap_area(a, b) == overlap_area(b, a)` |
184+
| `OverlapAreaSelfAndNonNegative` | Self-overlap equals own length; overlap area is never negative |
185+
| `OverlapsConsistentWithArea` | `overlaps(a, b)` iff `overlap_area(a, b) > a.length / 2` (overlap is relative to the first argument, not symmetric) |
186+
| `CalculateErrorZeroAtExactTiming` | Error is exactly 0 when pulse length matches `ACODE_TIMING(acode)` |
187+
| `CalculateErrorIsMinOfTwoDistances` | Error equals `min(distance to ACODE_TIMING(acode), distance to ACODE_TIMING(acode\|DATA_BIT))` |
188+
189+
Two test assumptions were wrong on the first pass and corrected against the actual contract:
190+
`overlaps()` measures overlap relative to the *first* argument's length (not a symmetric
191+
"area > 0" test), and `find_acode`'s lower bucket boundary is exclusive (`pulseLen < 2500+offset`
192+
returns -1, but `pulseLen == 2500+offset` falls into bucket 0).
193+
164194
## Summary
165195

166196
| Suite | File | Properties | Trials |
@@ -173,7 +203,8 @@ At the default threshold 0.0, sensors within 90° of the lighthouse are accepted
173203
| Reprojection Residual | `reproject_residual_props.c` | 6 | 6,000 |
174204
| Event Queue | `event_queue_props.c` | 7 | ~3,500 |
175205
| Normal Filter | `normal_filter_props.c` | 3 | 30,000 |
176-
| **Total** | | **71** | |
206+
| Pulse-Timing Decode | `disambiguator_props.c` | 8 | ~70,000 |
207+
| **Total** | | **79** | |
177208

178209
## Running the Tests
179210

@@ -194,6 +225,7 @@ ctest --output-on-failure
194225
./src/test_cases/test-reproject_residual_props
195226
./src/test_cases/test-event_queue_props
196227
./src/test_cases/test-normal_filter_props
228+
./src/test_cases/test-disambiguator_props
197229
```
198230

199231
Tests also run automatically in CI (`ci-property-tests.yml`) on every push and PR. Under ASan/UBSan, the random inputs also catch undefined behavior and memory errors.

src/disambiguator_statebased.c

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,9 @@ typedef struct {
213213
LightcapElement sweep_data[];
214214
} Disambiguator_data_t;
215215

216-
static int find_acode(uint32_t pulseLen) {
216+
// Non-static so disambiguator_props.c can property-test the pulse-timing
217+
// decode layer directly, without needing a full SurviveContext/SurviveObject.
218+
int find_acode(uint32_t pulseLen) {
217219
const static int offset = 50;
218220
if (pulseLen < 2500 + offset)
219221
return -1;
@@ -238,7 +240,8 @@ static int find_acode(uint32_t pulseLen) {
238240
return -1;
239241
}
240242

241-
static int32_t overlap_area(const LightcapElement *a, const LightcapElement *b) {
243+
// Non-static for disambiguator_props.c; see find_acode comment above.
244+
int32_t overlap_area(const LightcapElement *a, const LightcapElement *b) {
242245
if (a->timestamp > b->timestamp)
243246
return overlap_area(b, a);
244247

@@ -257,7 +260,8 @@ static int32_t overlap_area(const LightcapElement *a, const LightcapElement *b)
257260

258261
return c_end - c_start;
259262
}
260-
static bool overlaps(const LightcapElement *a, const LightcapElement *b) {
263+
// Non-static for disambiguator_props.c; see find_acode comment above.
264+
bool overlaps(const LightcapElement *a, const LightcapElement *b) {
261265
int overlap = overlap_area(a, b);
262266
return overlap > a->length / 2;
263267
}
@@ -327,7 +331,8 @@ static Disambiguator_data_t *get_best_latest_state(Global_Disambiguator_data_t *
327331
return best_d;
328332
}
329333

330-
static uint32_t calculate_error(int target_acode, const LightcapElement *le) {
334+
// Non-static for disambiguator_props.c; see find_acode comment above.
335+
uint32_t calculate_error(int target_acode, const LightcapElement *le) {
331336
// Calculate what it would be with and without data
332337
uint32_t time_error_d0 = abs(ACODE_TIMING(target_acode) - le->length);
333338
uint32_t time_error_d1 = abs(ACODE_TIMING(target_acode | DATA_BIT) - le->length);

src/test_cases/CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ SET(SURVIVE_TESTS
77
reproject_residual_props event_queue_props
88
residual_cascade_props
99
variance_gate_props
10-
normal_filter_props)
10+
normal_filter_props
11+
disambiguator_props)
1112

1213
set(barycentric_svd_ADDITIONAL_SRCS ../barycentric_svd/barycentric_svd.c)
1314
set(reproject_residual_props_ADDITIONAL_SRCS ../barycentric_svd/barycentric_svd.c)
15+
set(disambiguator_props_ADDITIONAL_SRCS ../disambiguator_statebased.c)
1416

1517
IF(NOT WIN32)
1618
LIST(APPEND SURVIVE_TESTS watchman)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Property-based tests for the pulse-timing decode layer in
2+
// disambiguator_statebased.c — find_acode, overlap_area, overlaps, and
3+
// calculate_error. These are the pure functions that turn a raw USB
4+
// LightcapElement (sensor_id, pulse length, timestamp) into an acode
5+
// classification and overlap/error metrics, upstream of all sync/sweep
6+
// state tracking. No SurviveContext/SurviveObject required.
7+
//
8+
// disable_lighthouse, find_inliers, find_relative_offset, and the rest of
9+
// the state machine are not covered here: they need Disambiguator_data_t /
10+
// SurviveContext and are out of scope for a pure-function property suite.
11+
12+
#include "test_case.h"
13+
#include <math.h>
14+
#include <stdint.h>
15+
#include <stdio.h>
16+
#include <stdlib.h>
17+
#include <survive_types.h>
18+
#include <time.h>
19+
20+
#define N_TRIALS 10000
21+
22+
// Declared non-static in disambiguator_statebased.c for this test file.
23+
int find_acode(uint32_t pulseLen);
24+
int32_t overlap_area(const LightcapElement *a, const LightcapElement *b);
25+
bool overlaps(const LightcapElement *a, const LightcapElement *b);
26+
uint32_t calculate_error(int target_acode, const LightcapElement *le);
27+
extern const int DATA_BIT;
28+
29+
#define ACODE_TIMING(acode) ((3000 + ((acode) & 1) * 500 + (((acode) >> 1) & 1) * 1000 + (((acode) >> 2) & 1) * 2000) - 250)
30+
31+
static uint32_t rand_u32(uint32_t min, uint32_t max) {
32+
return min + (uint32_t)((double)(max - min) * ((double)rand() / (double)RAND_MAX));
33+
}
34+
35+
// 1. find_acode never returns outside its documented range [-1, 7].
36+
TEST(DisambiguatorProps, FindAcodeRange) {
37+
unsigned seed = (unsigned)time(NULL);
38+
srand(seed);
39+
40+
for (int t = 0; t < N_TRIALS; t++) {
41+
uint32_t pulseLen = rand_u32(0, 20000);
42+
int acode = find_acode(pulseLen);
43+
if (acode < -1 || acode > 7) {
44+
fprintf(stderr, "FindAcodeRange FAILED (seed=%u, pulseLen=%u): acode=%d out of [-1,7]\n",
45+
seed, pulseLen, acode);
46+
return -1;
47+
}
48+
}
49+
return 0;
50+
}
51+
52+
// 2. find_acode is monotonic non-decreasing in pulseLen (each 500-tick
53+
// bucket maps to the next acode; never jumps backward).
54+
TEST(DisambiguatorProps, FindAcodeMonotonic) {
55+
int prev = find_acode(0);
56+
for (uint32_t pulseLen = 1; pulseLen < 20000; pulseLen++) {
57+
int acode = find_acode(pulseLen);
58+
if (acode != -1 && prev != -1 && acode < prev) {
59+
fprintf(stderr, "FindAcodeMonotonic FAILED at pulseLen=%u: acode=%d < prev=%d\n",
60+
pulseLen, acode, prev);
61+
return -1;
62+
}
63+
if (acode != -1)
64+
prev = acode;
65+
}
66+
return 0;
67+
}
68+
69+
// 3. find_acode at each bucket's documented center matches the expected
70+
// acode (regression-pins the offset=50, 500-tick-wide bucket table).
71+
TEST(DisambiguatorProps, FindAcodeBucketCenters) {
72+
struct {
73+
uint32_t pulseLen;
74+
int expected;
75+
} cases[] = {
76+
{2500 + 50 + 1, 0}, {3000 + 50 + 1, 1}, {3500 + 50 + 1, 2}, {4000 + 50 + 1, 3},
77+
{4500 + 50 + 1, 4}, {5000 + 50 + 1, 5}, {5500 + 50 + 1, 6}, {6000 + 50 + 1, 7},
78+
};
79+
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
80+
int acode = find_acode(cases[i].pulseLen);
81+
if (acode != cases[i].expected) {
82+
fprintf(stderr, "FindAcodeBucketCenters FAILED: pulseLen=%u expected=%d got=%d\n",
83+
cases[i].pulseLen, cases[i].expected, acode);
84+
return -1;
85+
}
86+
}
87+
// Below the lowest bucket and above the highest -> reject (-1), never
88+
// silently clamp into a valid acode. The lower bound is exclusive
89+
// (pulseLen < 2500+offset), so 2500+offset itself is bucket 0, not -1.
90+
if (find_acode(0) != -1 || find_acode(2500 + 50 - 1) != -1 || find_acode(6500 + 50) != -1) {
91+
fprintf(stderr, "FindAcodeBucketCenters FAILED: out-of-range pulseLen did not return -1\n");
92+
return -1;
93+
}
94+
return 0;
95+
}
96+
97+
// 4. overlap_area is symmetric: overlap_area(a, b) == overlap_area(b, a).
98+
TEST(DisambiguatorProps, OverlapAreaSymmetric) {
99+
unsigned seed = (unsigned)time(NULL);
100+
srand(seed);
101+
102+
for (int t = 0; t < N_TRIALS; t++) {
103+
LightcapElement a = {.sensor_id = 0, .timestamp = rand_u32(0, 1000000), .length = (uint16_t)rand_u32(0, 10000)};
104+
LightcapElement b = {.sensor_id = 1, .timestamp = rand_u32(0, 1000000), .length = (uint16_t)rand_u32(0, 10000)};
105+
106+
int32_t ab = overlap_area(&a, &b);
107+
int32_t ba = overlap_area(&b, &a);
108+
if (ab != ba) {
109+
fprintf(stderr, "OverlapAreaSymmetric FAILED (seed=%u, trial=%d): overlap_area(a,b)=%d != overlap_area(b,a)=%d\n",
110+
seed, t, ab, ba);
111+
return -1;
112+
}
113+
}
114+
return 0;
115+
}
116+
117+
// 5. overlap_area of an element with itself equals its own length (full
118+
// self-overlap); never negative.
119+
TEST(DisambiguatorProps, OverlapAreaSelfAndNonNegative) {
120+
unsigned seed = (unsigned)time(NULL);
121+
srand(seed);
122+
123+
for (int t = 0; t < N_TRIALS; t++) {
124+
LightcapElement a = {.sensor_id = 0, .timestamp = rand_u32(0, 1000000), .length = (uint16_t)rand_u32(1, 10000)};
125+
126+
int32_t self_overlap = overlap_area(&a, &a);
127+
if (self_overlap != a.length) {
128+
fprintf(stderr, "OverlapAreaSelfAndNonNegative FAILED (seed=%u, trial=%d): self overlap=%d expected length=%u\n",
129+
seed, t, self_overlap, a.length);
130+
return -1;
131+
}
132+
133+
LightcapElement b = {.sensor_id = 1, .timestamp = rand_u32(0, 1000000), .length = (uint16_t)rand_u32(0, 10000)};
134+
int32_t ab = overlap_area(&a, &b);
135+
if (ab < 0) {
136+
fprintf(stderr, "OverlapAreaSelfAndNonNegative FAILED (seed=%u, trial=%d): overlap=%d < 0\n",
137+
seed, t, ab);
138+
return -1;
139+
}
140+
}
141+
return 0;
142+
}
143+
144+
// 6. overlaps(a, b) is true iff overlap_area(a, b) exceeds half of a's own
145+
// length (the actual contract in disambiguator_statebased.c — overlap
146+
// is measured relative to the first argument, not a symmetric >0 test).
147+
TEST(DisambiguatorProps, OverlapsConsistentWithArea) {
148+
unsigned seed = (unsigned)time(NULL);
149+
srand(seed);
150+
151+
for (int t = 0; t < N_TRIALS; t++) {
152+
LightcapElement a = {.sensor_id = 0, .timestamp = rand_u32(0, 100000), .length = (uint16_t)rand_u32(1, 5000)};
153+
LightcapElement b = {.sensor_id = 1, .timestamp = rand_u32(0, 100000), .length = (uint16_t)rand_u32(1, 5000)};
154+
155+
bool ov = overlaps(&a, &b);
156+
int32_t area = overlap_area(&a, &b);
157+
bool expected = area > a.length / 2;
158+
if (ov != expected) {
159+
fprintf(stderr, "OverlapsConsistentWithArea FAILED (seed=%u, trial=%d): overlaps=%d but area=%d a.length=%u\n",
160+
seed, t, ov, area, a.length);
161+
return -1;
162+
}
163+
}
164+
return 0;
165+
}
166+
167+
// 7. calculate_error is symmetric in the DATA_BIT choice it's meant to
168+
// disambiguate: comparing a length exactly at ACODE_TIMING(acode) gives
169+
// zero error against that acode, and is always <= the d0/d1 distance by
170+
// construction (calculate_error takes the min of the two).
171+
TEST(DisambiguatorProps, CalculateErrorZeroAtExactTiming) {
172+
for (int acode = 0; acode < 8; acode++) {
173+
LightcapElement le = {.sensor_id = 0, .timestamp = 0, .length = (uint16_t)ACODE_TIMING(acode)};
174+
uint32_t err = calculate_error(acode, &le);
175+
if (err != 0) {
176+
fprintf(stderr, "CalculateErrorZeroAtExactTiming FAILED: acode=%d length=%d err=%u (expected 0)\n",
177+
acode, le.length, err);
178+
return -1;
179+
}
180+
}
181+
return 0;
182+
}
183+
184+
// 8. calculate_error never exceeds the distance to the un-databit timing
185+
// (it takes the min of d0/d1, so it's always <= either individually).
186+
TEST(DisambiguatorProps, CalculateErrorIsMinOfTwoDistances) {
187+
unsigned seed = (unsigned)time(NULL);
188+
srand(seed);
189+
190+
for (int t = 0; t < N_TRIALS; t++) {
191+
int acode = rand() % 8;
192+
LightcapElement le = {.sensor_id = 0, .timestamp = 0, .length = (uint16_t)rand_u32(2000, 7000)};
193+
194+
uint32_t err = calculate_error(acode, &le);
195+
uint32_t d0 = (uint32_t)abs((int)ACODE_TIMING(acode) - (int)le.length);
196+
uint32_t d1 = (uint32_t)abs((int)ACODE_TIMING(acode | DATA_BIT) - (int)le.length);
197+
uint32_t expected_min = d0 < d1 ? d0 : d1;
198+
199+
if (err != expected_min) {
200+
fprintf(stderr, "CalculateErrorIsMinOfTwoDistances FAILED (seed=%u, trial=%d): acode=%d length=%d err=%u expected=%u\n",
201+
seed, t, acode, le.length, err, expected_min);
202+
return -1;
203+
}
204+
}
205+
return 0;
206+
}

0 commit comments

Comments
 (0)