Skip to content

Commit 395eefc

Browse files
committed
feat(periodic-search): make periodic search solver settings visible
1 parent b2c3969 commit 395eefc

11 files changed

Lines changed: 257 additions & 16 deletions

frontend/src/Anton.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
shouldRecordTrajectoryHistoryPoint
2828
} from './utils/trajectoryState';
2929
import {
30+
buildVerifiedBoundaryCycles,
3031
collectGeometricOffsetBoundaryPoints,
3132
collectExtendedManifoldBranches,
3233
} from './utils/geometricOffsetSeed';
@@ -497,11 +498,20 @@ const SetValuedViz = () => {
497498
[boundarySourceManifolds]
498499
);
499500

500-
const calculatedBoundaryBranches = useMemo(
501-
() => collectExtendedManifoldBranches(boundarySourceManifolds),
501+
const verifiedBoundaryCycles = useMemo(
502+
() => buildVerifiedBoundaryCycles(boundarySourceManifolds),
502503
[boundarySourceManifolds]
503504
);
504505

506+
const calculatedBoundaryBranches = useMemo(
507+
() => verifiedBoundaryCycles.length > 0
508+
? verifiedBoundaryCycles
509+
: collectExtendedManifoldBranches(boundarySourceManifolds),
510+
[boundarySourceManifolds, verifiedBoundaryCycles]
511+
);
512+
513+
const hasVerifiedBoundaryCycles = verifiedBoundaryCycles.length > 0;
514+
505515
const boundaryLayers = useMemo(() => {
506516
if (calculatedBoundaryBranches.length === 0) {
507517
return {
@@ -2478,6 +2488,7 @@ const SetValuedViz = () => {
24782488
3.4,
24792489
0.24,
24802490
'unstable-manifold',
2491+
hasVerifiedBoundaryCycles,
24812492
);
24822493
}
24832494
});
@@ -2498,6 +2509,7 @@ const SetValuedViz = () => {
24982509
2.6,
24992510
0.23,
25002511
'deterministic-image',
2512+
hasVerifiedBoundaryCycles,
25012513
);
25022514
}
25032515
});
@@ -2806,7 +2818,7 @@ const SetValuedViz = () => {
28062818
scene.add(sphere);
28072819
}
28082820

2809-
}, [manifoldState, geometricOffsetState, bdeState, dynamicSystem, type, viewRange, readViewportSize, geometricOffsetBoundaryPoints, boundaryLayers, hasBoundarySamples, params.epsilon]);
2821+
}, [manifoldState, geometricOffsetState, bdeState, dynamicSystem, type, viewRange, readViewportSize, geometricOffsetBoundaryPoints, boundaryLayers, hasBoundarySamples, hasVerifiedBoundaryCycles, params.epsilon]);
28102822

28112823
useEffect(() => {
28122824
if (!sceneRef.current) return;

frontend/src/components/sidebar/PeriodicSearchPanel.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ describe('PeriodicSearchPanel', () => {
8080
thetaGridSize: 24,
8181
residualThreshold: 1e-10,
8282
useContinuation: false,
83+
maxNewtonIterations: 100,
84+
newtonBeta: null,
85+
deduplicationTolerance: 1e-3,
86+
supportFilterSubdivisions: 64,
87+
supportThreshold: 1e-10,
8388
});
8489
});
8590

@@ -113,4 +118,21 @@ describe('PeriodicSearchPanel', () => {
113118
const result = screen.getByLabelText('Current periodic orbit result configuration');
114119
expect(result).toHaveTextContent('ε = 0.0625; P ≤ 4, 8 × 8 positions, 12 angles, tolerance 1e-9');
115120
});
121+
122+
it('allows configuring advanced solver settings', () => {
123+
const onUpdate = vi.fn();
124+
render(<PeriodicSearchPanel {...baseProps} updatePeriodicSearchSettings={onUpdate} />);
125+
126+
fireEvent.change(screen.getByLabelText('Max Newton iterations'), { target: { value: '250' } });
127+
expect(onUpdate).toHaveBeenCalledWith({ maxNewtonIterations: 250 });
128+
129+
fireEvent.change(screen.getByLabelText('Damping β (Davidchack-Lai)'), { target: { value: '1.5' } });
130+
expect(onUpdate).toHaveBeenCalledWith({ newtonBeta: 1.5 });
131+
132+
fireEvent.change(screen.getByLabelText('Damping β (Davidchack-Lai)'), { target: { value: 'auto' } });
133+
expect(onUpdate).toHaveBeenCalledWith({ newtonBeta: null });
134+
135+
fireEvent.change(screen.getByLabelText('Deduplication tolerance'), { target: { value: '1e-4' } });
136+
expect(onUpdate).toHaveBeenCalledWith({ deduplicationTolerance: 1e-4 });
137+
});
116138
});

frontend/src/components/sidebar/PeriodicSearchPanel.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ export const PeriodicSearchPanel = ({
6767
updatePeriodicSearchSettings?.({ useContinuation: value });
6868
};
6969

70+
const updateMaxNewtonIterations = (e: ChangeEvent<HTMLInputElement>): void => {
71+
const val = parseInt(e.target.value, 10);
72+
updatePeriodicSearchSettings?.({ maxNewtonIterations: Number.isFinite(val) ? val : 100 });
73+
};
74+
75+
const updateNewtonBeta = (e: ChangeEvent<HTMLInputElement>): void => {
76+
const raw = e.target.value.trim();
77+
if (raw === '' || raw.toLowerCase() === 'auto') {
78+
updatePeriodicSearchSettings?.({ newtonBeta: null });
79+
} else {
80+
const val = parseFloat(raw);
81+
updatePeriodicSearchSettings?.({ newtonBeta: Number.isFinite(val) ? val : null });
82+
}
83+
};
84+
85+
const updateDeduplicationTolerance = (e: ChangeEvent<HTMLInputElement>): void => {
86+
updatePeriodicSearchSettings?.({ deduplicationTolerance: Number(e.target.value) });
87+
};
88+
7089
const seedCount = estimatePeriodicGridSeedCount(
7190
maxPeriod,
7291
periodicSearchSettings.gridSize,
@@ -177,6 +196,51 @@ export const PeriodicSearchPanel = ({
177196
<div><span>Newton starts</span><strong>{seedCount.toLocaleString()}</strong></div>
178197
</div>
179198

199+
<Collapsible title="Advanced solver settings" defaultOpen={false}>
200+
<div className="periodic-search-grid">
201+
<div className="start-field">
202+
<label htmlFor="periodic-max-iterations">Max Newton iterations</label>
203+
<input
204+
id="periodic-max-iterations"
205+
type="number"
206+
min="10"
207+
max="1000"
208+
step="10"
209+
value={periodicSearchSettings?.maxNewtonIterations ?? 100}
210+
onChange={updateMaxNewtonIterations}
211+
disabled={disabled}
212+
/>
213+
<small>Steps per seed before giving up</small>
214+
</div>
215+
<div className="start-field">
216+
<label htmlFor="periodic-newton-beta">Damping β (Davidchack-Lai)</label>
217+
<input
218+
id="periodic-newton-beta"
219+
type="text"
220+
placeholder="Auto (15·1.3^p)"
221+
value={periodicSearchSettings?.newtonBeta !== null && periodicSearchSettings?.newtonBeta !== undefined ? periodicSearchSettings.newtonBeta : ''}
222+
onChange={updateNewtonBeta}
223+
disabled={disabled}
224+
/>
225+
<small>0 for pure Newton; leave blank for Auto</small>
226+
</div>
227+
<div className="start-field">
228+
<label htmlFor="periodic-dedup-tolerance">Deduplication tolerance</label>
229+
<input
230+
id="periodic-dedup-tolerance"
231+
type="number"
232+
min="1e-6"
233+
max="1e-1"
234+
step="any"
235+
value={periodicSearchSettings?.deduplicationTolerance ?? 1e-3}
236+
onChange={updateDeduplicationTolerance}
237+
disabled={disabled}
238+
/>
239+
<small>Cluster radius to merge duplicate orbits</small>
240+
</div>
241+
</div>
242+
</Collapsible>
243+
180244
<Toggle
181245
label="Use cached-orbit continuation on ordinary compute"
182246
checked={Boolean(periodicSearchSettings?.useContinuation)}

frontend/src/components/ui/Collapsible.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ interface CollapsibleProps {
44
title: string;
55
children: ReactNode;
66
defaultOpen?: boolean;
7+
className?: string;
78
}
89

9-
export const Collapsible = ({ title, children, defaultOpen = true }: CollapsibleProps) => {
10+
export const Collapsible = ({
11+
title,
12+
children,
13+
defaultOpen = true,
14+
className = '',
15+
}: CollapsibleProps) => {
1016
const [isOpen, setIsOpen] = useState(defaultOpen);
1117
const panelId = `section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
1218

1319
return (
14-
<div className={`section ${isOpen ? 'open' : ''}`}>
20+
<div className={`section ${isOpen ? 'open' : ''} ${className}`.trim()}>
1521
<button
1622
type="button"
1723
className="sec-head"

frontend/src/compute.worker.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,13 @@ const computePeriodic = async (payload: PeriodicComputePayload): Promise<Periodi
262262
let support = null;
263263

264264
if (dynamicSystem === 'henon') {
265+
const supportSubdivisions = periodicSearchSettings.supportFilterSubdivisions ?? MIS_FILTER_SUBDIVISIONS;
266+
const supportThreshold = periodicSearchSettings.supportThreshold ?? MIS_SUPPORT_THRESHOLD;
267+
265268
supportComputer = new wasm.UlamComputer(
266269
params.a,
267270
params.b,
268-
MIS_FILTER_SUBDIVISIONS,
271+
supportSubdivisions,
269272
MIS_FILTER_POINTS_PER_BOX,
270273
params.epsilon,
271274
viewRange.xMin,
@@ -276,12 +279,12 @@ const computePeriodic = async (payload: PeriodicComputePayload): Promise<Periodi
276279

277280
support = {
278281
invariantMeasure: supportComputer.get_invariant_measure() as number[],
279-
subdivisions: MIS_FILTER_SUBDIVISIONS,
282+
subdivisions: supportSubdivisions,
280283
xMin: viewRange.xMin,
281284
xMax: viewRange.xMax,
282285
yMin: viewRange.yMin,
283286
yMax: viewRange.yMax,
284-
threshold: MIS_SUPPORT_THRESHOLD
287+
threshold: supportThreshold
285288
};
286289

287290
orbits = filterOrbitsBySupport(orbits, support);

frontend/src/index.css

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,32 @@ select:disabled { cursor: not-allowed; }
296296
transition: transform .18s;
297297
color: var(--text-3);
298298
}
299-
.section.open .sec-caret { transform: rotate(90deg); }
299+
.section.open > .sec-head .sec-caret { transform: rotate(90deg); }
300300
.sec-body {
301301
padding: 2px 14px 14px;
302302
display: none;
303303
}
304-
.section.open .sec-body { display: block; }
304+
.section.open > .sec-body { display: block; }
305+
306+
/* Sub-collapsible inside sections */
307+
.section .section {
308+
border: 1px solid var(--line);
309+
border-radius: 6px;
310+
margin: 10px 0;
311+
background: rgba(255, 255, 255, 0.015);
312+
}
313+
.section .section .sec-head {
314+
min-height: 36px;
315+
padding: 8px 12px;
316+
}
317+
.section .section .sec-title {
318+
font-size: 11px;
319+
font-weight: 650;
320+
color: var(--text-2);
321+
}
322+
.section .section .sec-body {
323+
padding: 8px 12px 12px;
324+
}
305325

306326
/* ─── Param row ─────────────────────────────────────── */
307327
.p-row { margin-bottom: 13px; }

frontend/src/test/PeriodicSearchSettings.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,37 @@ describe('normalizePeriodicSearchSettings', () => {
5757
thetaGridSize: 12,
5858
residualThreshold: 1e-9,
5959
useContinuation: true,
60+
maxNewtonIterations: 200,
61+
newtonBeta: 2.5,
62+
deduplicationTolerance: 5e-4,
63+
supportFilterSubdivisions: 32,
64+
supportThreshold: 1e-8,
6065
})).toEqual({
6166
gridSize: 16,
6267
thetaGridSize: 12,
6368
residualThreshold: 1e-9,
6469
useContinuation: false,
70+
maxNewtonIterations: 200,
71+
newtonBeta: 2.5,
72+
deduplicationTolerance: 5e-4,
73+
supportFilterSubdivisions: 32,
74+
supportThreshold: 1e-8,
6575
});
6676
});
77+
78+
it('normalizes advanced solver parameters within valid bounds', () => {
79+
const normalized = normalizePeriodicSearchSettings({
80+
maxNewtonIterations: 5,
81+
newtonBeta: -10,
82+
deduplicationTolerance: 1e-8,
83+
supportFilterSubdivisions: 512,
84+
supportThreshold: 0,
85+
});
86+
87+
expect(normalized.maxNewtonIterations).toBe(10);
88+
expect(normalized.newtonBeta).toBe(0);
89+
expect(normalized.deduplicationTolerance).toBe(1e-6);
90+
expect(normalized.supportFilterSubdivisions).toBe(256);
91+
expect(normalized.supportThreshold).toBe(1e-10);
92+
});
6793
});

frontend/src/types/domain.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export interface PeriodicSearchSettings {
6969
thetaGridSize: number;
7070
residualThreshold: number;
7171
useContinuation: boolean;
72+
maxNewtonIterations?: number;
73+
newtonBeta?: number | null;
74+
deduplicationTolerance?: number;
75+
supportFilterSubdivisions?: number;
76+
supportThreshold?: number;
7277
}
7378

7479
export interface PeriodicOrbit extends UnknownRecord {

frontend/src/utils/experimentBundle.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,11 @@ export const experimentConfigurationToUiState = (configuration: unknown) => {
12151215
thetaGridSize: value.solvers.periodicSearch.thetaGridSize,
12161216
residualThreshold: value.solvers.periodicSearch.residualThreshold,
12171217
useContinuation: value.solvers.periodicSearch.useContinuation,
1218+
maxNewtonIterations: value.solvers.periodicSearch.maxNewtonIterations,
1219+
newtonBeta: value.solvers.periodicSearch.newtonBeta,
1220+
deduplicationTolerance: value.solvers.periodicSearch.deduplicationTolerance,
1221+
supportFilterSubdivisions: value.solvers.periodicSearch.supportFilterSubdivisions,
1222+
supportThreshold: value.solvers.periodicSearch.supportThreshold,
12181223
},
12191224
startPoint: cloneJsonValue(value.initialExtendedState),
12201225
manifoldSettings: cloneJsonValue(value.solvers.manifold),

frontend/src/utils/geometricOffsetSeed.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,25 @@ describe('verified geometric-offset seed', () => {
9393
expect(buildVerifiedBoundaryCycle(manifolds)).toEqual([]);
9494
});
9595

96+
it('preserves two independent invariant lobes as separate verified cycles', () => {
97+
const leftLower = [point(0, 0), point(0.5, -0.1), point(1, 0)];
98+
const leftUpper = [point(1, 0.4), point(0.5, 0.5), point(0, 0.4)];
99+
const rightLower = [point(2, 0), point(2.5, -0.1), point(3, 0)];
100+
const rightUpper = [point(3, 0.4), point(2.5, 0.5), point(2, 0.4)];
101+
102+
const cycles = buildVerifiedBoundaryCycles([
103+
arc(10, 11, leftLower),
104+
arc(11, 10, leftUpper),
105+
arc(20, 21, rightLower),
106+
arc(21, 20, rightUpper),
107+
]);
108+
109+
expect(cycles).toEqual([
110+
[...leftLower, ...leftUpper],
111+
[...rightLower, ...rightUpper],
112+
]);
113+
});
114+
96115
it('keeps every calculated point without seed downsampling', () => {
97116
const upper = Array.from({ length: 2050 }, (_, index) => point(index / 2049, 0));
98117
const lower = Array.from({ length: 2050 }, (_, index) => point(1 - index / 2049, 1));

0 commit comments

Comments
 (0)