Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,10 @@ If you are unsure where your change belongs, start here:

## Terminology

Praxis now distinguishes between several kinds of interactive modules:

1. `Algorithm`: a runnable algorithm implementation such as BFS, A*, Minimax, or Simulated Annealing.
2. `Game`: an interactive problem module with game-specific rendering and controls, such as Tic-Tac-Toe or Maze Game.
3. `Sandbox`: a general-purpose experimentation surface, such as Graph Sandbox.
4. `Lab`: a structured educational module, currently used most heavily in local search.
5. `Discovery item`: any surfaced module shown on the home Playgrounds tab or in module search.
2. `Playground`: the editor for one algorithm family — the single user-facing concept for every interactive module. A playground lets you set up a custom problem and step through it. We used to split these into "game", "sandbox", and "lab"; that distinction was cosmetic and has been removed. `DiscoveryItem.kind` is always `'playground'`, and the home page surfaces every module with one uniform badge.
3. `Family`: the internal code grouping a playground belongs to — `search`, `maze`, `game-playing`, `local-search`, `planning`, or `constraint-satisfaction`. Families are an implementation detail (each has its own `src/problems/<family>/` directory and registry), not a user-facing category.
4. `Discovery item`: any surfaced playground shown on the home Playgrounds tab or in module search.

## Architecture Map

Expand Down Expand Up @@ -190,9 +187,9 @@ The local-search page is generic. It expects each lab to provide:
6. `createDefaultProblem()`
7. `normalizeImportedProblem(problem)`
8. `randomizeProblem(problem)`
9. `renderSetupSection(context)`
10. `renderBoardTab(context)`
11. `renderNeighborhoodTab(context)`
9. `renderSetupSection(context)` — the lab-specific setup section appended to the page's config sidebar
10. `renderTabs(context)` — the unified tab contract (Problem View, Neighborhood, Objective, Trajectory), shared with every other family's page shell so each page calls `activeLab.renderTabs(context)` the same way
11. `renderBoardTab(context)` / `renderNeighborhoodTab(context)` — lab-specific views composed by `renderTabs`; objective and trajectory are family-wide

These are defined in `src/problems/local-search/labs.ts` and implemented in `src/problems/local-search/lab-modules.tsx`.

Expand Down Expand Up @@ -289,7 +286,7 @@ These contracts live in `src/problems/game-playing/lab-modules.tsx` and `src/pro

## Adding Or Updating Maze Game Entries

Maze is treated as a game surfaced in the discovery layer, but it currently still uses its own dedicated page implementation.
Maze now goes through a registry module (`MAZE_LAB_MODULE` in `src/problems/maze/lab-modules.tsx`): `MazePage` is a thin shell that owns the maze store, local config state, and effects, builds a `MazeLabContext`, and delegates rendering to the module's `renderConfigPanel` / `renderTabs` / `renderTitleActions`. Discovery entries still live in `src/problems/maze/labs.ts`.

### Files You Will Usually Touch

Expand Down Expand Up @@ -320,7 +317,7 @@ Do not edit `src/lib/discovery-items.ts` directly for Maze-specific entries. Reg

## Adding Or Updating Graph Sandbox Entries

Graph Sandbox is the general-purpose graph experimentation interface for search algorithms. It is not a game.
Graph Sandbox is the general-purpose graph experimentation playground for search algorithms. It deliberately remains a single cohesive page (`SearchPage.tsx`) rather than a registry module: it is a single-instance editor with ~7 local state hooks plus the editor store, so threading all of that through a pure-render module context would be messier than the page it replaces. It still renders through the shared `AlgorithmPage` shell, so it is consistent for users.

### Files You Will Usually Touch

Expand Down
4 changes: 2 additions & 2 deletions src/components/visualization/TicTacToeLab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export default function TicTacToeLab({ problem, step, onSetCell }: TicTacToeLabP
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--text-3)]">Evaluated Moves</p>
<span className="text-[10px] font-mono text-[var(--text-3)]">
{traceState?.evaluatedMoves.length ?? 0} scored
{traceState?.evaluatedMoves?.length ?? 0} scored
</span>
</div>
{traceState?.evaluatedMoves?.length ? (
Expand Down Expand Up @@ -186,7 +186,7 @@ export default function TicTacToeLab({ problem, step, onSetCell }: TicTacToeLabP
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--text-3)]">Recursion Stack</p>
<span className="text-[10px] font-mono text-[var(--text-3)]">
{traceState?.recursionStack.length ?? 0} frames
{traceState?.recursionStack?.length ?? 0} frames
</span>
</div>
{traceState?.recursionStack?.length ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from '@/lib/cn';
import { Graph, type GraphColoringProblem } from '@/types/problem';
import type { LocalSearchStep } from '@/algorithms/local-search/types';
import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared';
import { CandidateList, TraceNotes } from './LocalSearchShared';
import { normalizeGraphNodes } from '@/problems/local-search/graph-coloring';

const PALETTE = ['#F2C94C', '#58A6FF', '#53C880', '#FF7B72', '#D2A8FF', '#56D4DD', '#FFA657', '#7EE787'];
Expand Down Expand Up @@ -153,7 +153,6 @@ export function GraphColoringBoardTab({ problem, step, onCycleNode, onUpdateGrap
return (
<div className="h-full overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(83,200,128,0.14),transparent_28%),var(--bg)]">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<SummaryCards step={step} />
<div className="grid gap-4 lg:grid-cols-[minmax(320px,1fr)_minmax(320px,0.95fr)]">
<section>
<ColoringCanvas problem={problem} colors={colors} onCycleNode={onCycleNode} onUpdateGraph={onUpdateGraph} />
Expand Down
3 changes: 1 addition & 2 deletions src/components/visualization/local-search/LandscapeLab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cn } from '@/lib/cn';
import type { LandscapeProblem, LandscapeState } from '@/types/problem';
import type { LocalSearchStep } from '@/algorithms/local-search/types';
import { evaluateLandscape } from '@/problems/local-search/landscape';
import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared';
import { CandidateList, TraceNotes } from './LocalSearchShared';

interface LandscapeLabProps {
problem: LandscapeProblem;
Expand Down Expand Up @@ -144,7 +144,6 @@ export function LandscapeBoardTab({ problem, step, onSetInitialState }: Landscap
return (
<div className="h-full overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(83,200,128,0.14),transparent_28%),var(--bg)]">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<SummaryCards step={step} />
<div className="grid gap-4 lg:grid-cols-[minmax(320px,1fr)_minmax(320px,0.95fr)]">
<section>
<LandscapeSurface
Expand Down
22 changes: 0 additions & 22 deletions src/components/visualization/local-search/LocalSearchShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,6 @@ import type { LocalSearchStep } from '@/algorithms/local-search/types';
import type { LocalSearchProblem } from '@/types/problem';
import type { LocalSearchCandidate } from '@/problems/local-search/types';

export function SummaryCards({ step }: { step: LocalSearchStep | null }) {
const state = step?.state;
const objective = state?.objectiveLabel ?? 'Objective';
const cards = [
{ label: objective, value: state?.currentDisplayValue ?? '-', tone: 'text-[#F0883E]' },
{ label: `Best ${objective}`, value: state?.bestDisplayValue ?? '-', tone: 'text-[#3FB950]' },
{ label: 'Iteration', value: state?.iteration ?? 0, tone: 'text-[#58A6FF]' },
{ label: 'Restarts', value: state?.restartCount ?? 0, tone: 'text-[var(--text)]' },
];

return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{cards.map(card => (
<div key={card.label} className="rounded-2xl border border-[var(--border)] bg-[var(--surface)]/88 px-4 py-3">
<p className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--text-3)]">{card.label}</p>
<p className={cn('mt-2 text-2xl font-semibold', card.tone)}>{card.value}</p>
</div>
))}
</div>
);
}

export function CandidateList({
candidates,
acceptedMove,
Expand Down
3 changes: 1 addition & 2 deletions src/components/visualization/local-search/NPuzzleLab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from '@/lib/cn';
import type { NPuzzleProblem } from '@/types/problem';
import type { LocalSearchStep } from '@/algorithms/local-search/types';
import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared';
import { CandidateList, TraceNotes } from './LocalSearchShared';

interface NPuzzleLabProps {
problem: NPuzzleProblem;
Expand Down Expand Up @@ -72,7 +72,6 @@ export function NPuzzleBoardTab({ problem, step, onMoveTile }: NPuzzleLabProps)
return (
<div className="h-full overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(83,200,128,0.14),transparent_28%),var(--bg)]">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<SummaryCards step={step} />
<div className="grid gap-4 lg:grid-cols-[minmax(320px,0.8fr)_minmax(320px,1.2fr)]">
<section>
<PuzzleBoard problem={problem} tiles={tiles} onMoveTile={onMoveTile} step={step} />
Expand Down
4 changes: 1 addition & 3 deletions src/components/visualization/local-search/NQueensLab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from '@/lib/cn';
import type { NQueensProblem } from '@/types/problem';
import type { LocalSearchStep } from '@/algorithms/local-search/types';
import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared';
import { CandidateList, TraceNotes } from './LocalSearchShared';

interface SharedProps {
problem: NQueensProblem;
Expand Down Expand Up @@ -102,8 +102,6 @@ export function NQueensBoardTab({ problem, step, onSetQueen }: SharedProps) {
return (
<div className="h-full overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(83,200,128,0.14),transparent_28%),var(--bg)]">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<SummaryCards step={step} />

<div className="grid gap-4 lg:grid-cols-[minmax(320px,1fr)_minmax(320px,0.95fr)]">
<section>
<QueenBoard state={board} step={step} onSetQueen={onSetQueen} />
Expand Down
3 changes: 1 addition & 2 deletions src/components/visualization/local-search/TspLab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TspProblem } from '@/types/problem';
import type { LocalSearchStep } from '@/algorithms/local-search/types';
import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared';
import { CandidateList, TraceNotes } from './LocalSearchShared';
import SurfaceCard from '@/components/shared/SurfaceCard';

interface TspLabProps {
Expand Down Expand Up @@ -124,7 +124,6 @@ export function TspBoardTab({ problem, step, onRegenerate, onUpdateCities }: Tsp
return (
<div className="h-full overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(83,200,128,0.14),transparent_28%),var(--bg)]">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<SummaryCards step={step} />
<div className="grid gap-4 lg:grid-cols-[minmax(320px,1fr)_minmax(320px,0.95fr)]">
<section className="space-y-4">
<RouteCanvas problem={problem} route={route} />
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useAlgorithmPage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo, useEffect } from 'react';
import { registry } from '@/algorithms/core/registry';
import type { ExecutionLoadContext } from '@/store/execution.store';
import { useExecutionStore } from '@/store/execution.store';
import { useCurrentStep, useExecutionStore } from '@/store/execution.store';
import { usePlayback } from '@/hooks/usePlayback';
import type { AlgorithmRunner, AlgorithmStep } from '@/types';

Expand Down Expand Up @@ -41,7 +41,7 @@ export function useAlgorithmPage(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [algorithmId, problem, runner, context]);

const step = useExecutionStore(state => state.currentStep as AlgorithmStep | null);
const step = useCurrentStep<AlgorithmStep>(algorithmId);
const loadError = useExecutionStore(state => state.loadError);
const loadWarning = useExecutionStore(state => state.loadWarning);

Expand Down
110 changes: 45 additions & 65 deletions src/lib/discovery-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import { PLANNING_LAB_DEFINITIONS } from '@/problems/planning/labs';
import { SEARCH_LAB_DEFINITIONS } from '@/problems/search/labs';

export type DiscoveryItemStatus = 'live' | 'coming-soon';
export type DiscoveryItemKind = 'game' | 'sandbox' | 'lab';

/**
* Every interactive module is a "playground": the editor for one algorithm
* family where you set up a custom problem and step through it. We used to split
* these into game / sandbox / lab, but that distinction was cosmetic — there is
* one concept, surfaced uniformly. Keep it that way.
*/
export type DiscoveryItemKind = 'playground';

export interface DiscoveryItem {
id: string;
Expand All @@ -20,75 +27,48 @@ export interface DiscoveryItem {

export type DiscoveryItemsByCategory = Partial<Record<AlgorithmCategory, DiscoveryItem[]>>;

interface SourceDefinition {
id: string;
name: string;
description: string;
path?: string;
status?: DiscoveryItemStatus;
}

/**
* Uniform mapping from a family's lab registry to discovery items. The aggregator
* stays dumb: every family is surfaced the same way, with the same `playground`
* kind. The only per-family knob is an optional id suffix used to disambiguate
* module ids from their underlying lab ids.
*/
function toDiscoveryItems(
defs: SourceDefinition[],
options: { idSuffix?: string } = {},
): DiscoveryItem[] {
const { idSuffix = '' } = options;
return defs.map((def) => ({
id: `${def.id}${idSuffix}`,
name: def.name,
description: def.description,
path: def.path,
status: def.status ?? 'live',
kind: 'playground' as const,
Comment on lines +49 to +55
}));
}

export const DISCOVERY_ITEMS_BY_CATEGORY: DiscoveryItemsByCategory = {
'uninformed-search': [
...MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search').map((entry) => ({
id: entry.id,
name: entry.name,
description: entry.description,
path: entry.path,
status: entry.status,
kind: 'game' as const,
})),
...SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search').map((entry) => ({
id: entry.id,
name: entry.name,
description: entry.description,
path: entry.path,
status: entry.status,
kind: 'sandbox' as const,
})),
...toDiscoveryItems(MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search')),
...toDiscoveryItems(SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search')),
],
'informed-search': [
...MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search').map((entry) => ({
id: entry.id,
name: entry.name,
description: entry.description,
path: entry.path,
status: entry.status,
kind: 'game' as const,
})),
...SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search').map((entry) => ({
id: entry.id,
name: entry.name,
description: entry.description,
path: entry.path,
status: entry.status,
kind: 'sandbox' as const,
})),
...toDiscoveryItems(MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search')),
...toDiscoveryItems(SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search')),
],
'game-playing': GAME_PLAYING_LAB_DEFINITIONS.map((entry) => ({
id: `${entry.id}-lab`,
name: entry.name,
description: entry.description,
path: entry.path,
status: entry.status,
kind: 'game' as const,
})),
'local-search': LOCAL_SEARCH_LAB_DEFINITIONS.map((entry) => ({
id: `${entry.id}-lab`,
name: `${entry.name} Lab`,
description: entry.description,
path: entry.path,
status: 'live' as const,
kind: 'lab' as const,
})),
planning: PLANNING_LAB_DEFINITIONS.map((entry) => ({
id: `${entry.id}-lab`,
name: entry.name,
description: entry.description,
path: entry.path,
status: 'live' as const,
kind: 'lab' as const,
})),
'constraint-satisfaction': CSP_LAB_DEFINITIONS.map((entry) => ({
id: `${entry.id}-lab`,
name: entry.name,
description: entry.description,
path: entry.path,
status: 'live' as const,
kind: 'lab' as const,
})),
'game-playing': toDiscoveryItems(GAME_PLAYING_LAB_DEFINITIONS, { idSuffix: '-lab' }),
'local-search': toDiscoveryItems(LOCAL_SEARCH_LAB_DEFINITIONS, { idSuffix: '-lab' }),
planning: toDiscoveryItems(PLANNING_LAB_DEFINITIONS, { idSuffix: '-lab' }),
'constraint-satisfaction': toDiscoveryItems(CSP_LAB_DEFINITIONS, { idSuffix: '-lab' }),
};

export function getDiscoveryItemsForCategory(category: AlgorithmCategory): DiscoveryItem[] {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/CspPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import AlgorithmPage from '@/components/module/AlgorithmPage';
import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator';
import PresetPickerDialog from '@/components/shared/PresetPickerDialog';
import Select from '@/components/shared/Select';
import { useExecutionStore } from '@/store/execution.store';
import { useCurrentStep, useExecutionStore } from '@/store/execution.store';
import type { CspProblem } from '@/types/problem';
import type { CspStep } from '@/algorithms/csp/types';
import { createExecutionProblemKey } from '@/lib/execution-problem-key';
Expand Down Expand Up @@ -35,7 +35,7 @@ export default function CspPage() {
const resolvedLab = supportsCspAlgorithm(labParam, algo) ? labParam : fallbackLab;
const [problem, setProblem] = useState<CspProblem>(() => createDefaultCspProblem(resolvedLab));
const [problemKey, setProblemKey] = useState(`csp:${resolvedLab}:default`);
const step = useExecutionStore((state) => state.currentStep as CspStep | null);
const step = useCurrentStep<CspStep>(algo);
const currentIndex = useExecutionStore((state) => state.currentIndex);
const resetExecution = useExecutionStore((state) => state.reset);
const activeLab = getCspLabModule(problem.lab);
Expand Down
4 changes: 2 additions & 2 deletions src/pages/GamePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import type { AlgorithmStep } from '@/types/step';
import type { GameProblem } from '@/types/problem';
import { useExecutionStore } from '@/store/execution.store';
import { useCurrentStep, useExecutionStore } from '@/store/execution.store';
import AlgorithmPage from '@/components/module/AlgorithmPage';
import {
buildGamePlayingRoute,
Expand All @@ -24,7 +24,7 @@ export default function GamePage() {
const [problem, setProblem] = useState<GameProblem>(() => activeLab.createDefaultProblem());
const [problemKey, setProblemKey] = useState(`game:${resolvedLabId}:default`);
const [demoDialogOpen, setDemoDialogOpen] = useState(false);
const step = useExecutionStore(state => state.currentStep as AlgorithmStep<unknown, unknown> | null);
const step = useCurrentStep<AlgorithmStep<unknown, unknown>>(algo);
const clearExecution = useExecutionStore((state) => state.clear);

useEffect(() => {
Expand Down
Loading