feat: add capabilities

This commit is contained in:
Morten Olsen
2023-09-26 09:08:08 +02:00
parent a81afc9221
commit c00b073d37
43 changed files with 3284 additions and 305 deletions

View File

@@ -1,7 +1,12 @@
import { Page } from './containers/page';
import { NextUIProvider } from '@nextui-org/react';
import { Page } from './containers/experiment';
const App: React.FC = () => {
return <Page slug=".hello" />;
return (
<NextUIProvider>
<Page slug=".capabilities" />
</NextUIProvider>
);
};
export { App };

View File

@@ -1,28 +1,34 @@
import { useEffect, useState } from 'react';
import { pages } from '../utils/pages';
import { RunnerProvider } from '../features/runner';
import { experiments } from '../../utils/experiments';
import { ExperimentProvider } from '../../features/experiment/context';
import { ExperimentView } from './view';
type PageProps = {
slug: string;
};
type Experiment = {
worker: () => Worker;
view: React.ReactElement;
};
const Page: React.FC<PageProps> = ({ slug }) => {
const [Component, setComponent] = useState<React.FC>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
const [experiment, setExperiment] = useState<Experiment>();
useEffect(() => {
setLoading(true);
setError(undefined);
const load = async () => {
try {
const page = pages.find((page) => page.slug === slug);
const page = experiments.find((page) => page.slug === slug);
if (!page) {
throw new Error(`Page not found: ${slug}`);
}
const { default: Component } = (await page.loader()) as {
default: React.FC;
};
setComponent(() => Component);
const next = (await page.loader()) as Experiment;
console.log('n', next);
setExperiment(next);
} catch (err) {
setError(err);
} finally {
@@ -37,14 +43,14 @@ const Page: React.FC<PageProps> = ({ slug }) => {
return <div>Error: {error.toString()}</div>;
}
if (loading || !Component) {
if (loading || !experiment) {
return <div>Loading...</div>;
}
return (
<RunnerProvider>
<Component />
</RunnerProvider>
<ExperimentProvider worker={experiment.worker}>
<ExperimentView>{experiment.view}</ExperimentView>
</ExperimentProvider>
);
};

View File

@@ -0,0 +1,26 @@
import { Accordion, AccordionItem } from '@nextui-org/react';
import { useSelectedNode } from '../../features/experiment/hooks';
import { Plan } from '../../presenters/plan';
const NodeView = () => {
const node = useSelectedNode();
if (!node) {
return null;
}
return (
<div>
<h1>{node.id}</h1>
<Accordion>
<AccordionItem title="Plan" key="plan">
<Plan node={node} />
</AccordionItem>
<AccordionItem title="Context" key="context">
<pre>{JSON.stringify(node.context, null, 2)}</pre>
</AccordionItem>
</Accordion>
</div>
);
};
export { NodeView };

View File

@@ -0,0 +1,23 @@
import { useExperimentResult } from '../../features/experiment';
import { useSelectNode } from '../../features/experiment/hooks';
const NodesView = () => {
const data = useExperimentResult();
const selectNode = useSelectNode();
if (!data) {
return null;
}
return (
<div>
{data.completed.map((node) => (
<div onClick={() => selectNode(node)} key={node.id}>
{node.id} {node.score}
</div>
))}
</div>
);
};
export { NodesView };

View File

@@ -0,0 +1,19 @@
import { Graph } from '../../presenters/graph';
import { NodeView } from './node';
import { NodesView } from './nodes';
type ExperimentViewProps = {
children: React.ReactNode;
};
const ExperimentView: React.FC<ExperimentViewProps> = ({ children }) => {
return (
<>
<Graph />
<NodesView />
<NodeView />
</>
);
};
export { ExperimentView };

View File

@@ -0,0 +1,12 @@
const Foo = () => {
return <div></div>;
};
const worker = () =>
new Worker(new URL('./script.ts', import.meta.url), {
type: 'module',
});
const view = <Foo />;
export { worker, view };

View File

@@ -0,0 +1,181 @@
import { Bob, plugins } from '@bob-the-algorithm/core';
import { createWorker } from '../../features/experiment/worker';
const MIN = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const getTravelTime = async () => 30 * MIN;
const transport = plugins.transport({
getTravelTime,
});
const realistic = async () => {
try {
const bob = new Bob({
plugins: { transport, capabilities: plugins.capabilities() },
});
const result = await bob.run({
context: {
location: 'home',
capabilities: ['kids'],
},
start: 0,
heuristic: ({ completed }) => completed.length >= 30,
planables: [
{
id: `Brush teeth`,
duration: 2 * MIN,
start: {
min: 7 * HOUR,
max: 8 * HOUR,
},
attributes: {
locations: ['home'],
},
score: 1,
},
{
id: 'Drop off kids',
duration: 30 * MIN,
attributes: {
locations: ['daycare'],
capabilities: {
consumes: ['kids'],
requires: ['kids'],
},
},
score: 1,
start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
},
{
id: 'visit zoo',
duration: 1 * HOUR,
attributes: {
locations: ['zoo'],
capabilities: {
requires: ['kids'],
},
},
score: 1,
start: {
min: 10 * HOUR,
max: 14 * HOUR,
},
},
{
id: 'Pickup the kids',
duration: 30 * MIN,
attributes: {
locations: ['daycare'],
capabilities: {
provides: ['kids'],
},
},
score: 1,
start: {
min: 10 * HOUR,
max: 15.5 * HOUR,
},
},
{
id: 'Do work',
duration: 1 * HOUR,
count: 5,
attributes: {
locations: ['work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 8 * HOUR,
max: 18 * HOUR,
},
},
{
id: 'put kids to bed',
duration: 30 * MIN,
attributes: {
locations: ['home'],
capabilities: {
consumes: ['kids'],
requires: ['kids'],
},
},
score: 1,
},
{
id: 'Read book',
duration: 0.5 * HOUR,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 3,
count: 2,
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meditate',
duration: 10 * MIN,
score: 1,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meeting 1',
duration: 1 * HOUR,
attributes: {
locations: ['work', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 10 * HOUR,
max: 10 * HOUR,
},
},
{
id: 'Play playstation',
duration: 1 * HOUR,
attributes: {
locations: ['home'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 16 * HOUR,
max: 24 * HOUR,
},
},
],
});
return result;
} catch (e) {
console.error(e);
throw e;
}
};
createWorker(realistic);

View File

@@ -0,0 +1,12 @@
const Foo = () => {
return <div></div>;
};
const worker = () =>
new Worker(new URL('./script.ts', import.meta.url), {
type: 'module',
});
const view = <Foo />;
export { worker, view };

View File

@@ -1,6 +1,5 @@
import { Bob, plugins } from '@bob-the-algorithm/core';
import { createWorker } from '../../features/runner/worker';
import { convertResult } from '../../utils/graph';
import { createWorker } from '../../features/experiment/worker';
const MIN = 1000 * 60;
const HOUR = 1000 * 60 * 60;
@@ -13,7 +12,7 @@ const transport = plugins.transport({
const realistic = async () => {
try {
const bob = new Bob({
plugins: { transport },
plugins: { transport, capabilities: plugins.capabilities() },
});
const result = await bob.run({
context: {
@@ -144,13 +143,11 @@ const realistic = async () => {
},
],
});
return convertResult(result);
return result;
} catch (e) {
console.error(e);
throw e;
}
};
createWorker({
realistic,
});
createWorker(realistic);

View File

@@ -0,0 +1,99 @@
import { CalulationResult, GraphNode } from '@bob-the-algorithm/core';
import {
ReactNode,
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
type ExperimentResult = {
payload: CalulationResult<any>;
};
type ExperimentContextValue = {
result?: ExperimentResult;
error?: any;
loading: boolean;
selectNode: (node?: GraphNode) => void;
selectedNode?: GraphNode;
};
type ExperimentProviderProps = {
children: ReactNode;
worker: () => Worker;
};
const ExperimentContext = createContext<ExperimentContextValue>({
loading: false,
selectNode: () => { },
});
const ExperimentProvider: React.FC<ExperimentProviderProps> = ({
children,
worker,
}) => {
const [result, setResult] = useState<ExperimentResult>();
const [error, setError] = useState<any>();
const [loading, setLoading] = useState(false);
const [selectedNode, setSelectedNode] = useState<GraphNode>();
const selectNode = useCallback((node?: GraphNode) => {
setSelectedNode(node);
}, []);
useEffect(() => {
let workerInstance: Worker | undefined;
const run = async () => {
setLoading(true);
setError(undefined);
setResult(undefined);
const workerInstance = worker();
workerInstance.onmessage = (e) => {
switch (e.data.type) {
case 'error': {
setError(e.data);
break;
}
case 'output': {
setResult(e.data);
break;
}
}
setLoading(false);
};
workerInstance.onerror = (e) => {
setError(e);
setLoading(false);
};
workerInstance.postMessage({
type: 'run',
});
};
run();
return () => {
workerInstance?.terminate();
};
}, [worker]);
const value = useMemo(
() => ({
result,
error,
loading,
selectNode,
selectedNode,
}),
[result, error, loading, selectNode, selectedNode],
);
return (
<ExperimentContext.Provider value={value}>
{children}
</ExperimentContext.Provider>
);
};
export type { ExperimentContextValue };
export { ExperimentContext, ExperimentProvider };

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import { ExperimentProvider } from './context';
type CreateExperimentInput = {
worker: () => Worker;
view: ReactNode;
};
const createExperiment = (input: CreateExperimentInput) => {
return (
<ExperimentProvider worker={input.worker}>{input.view}</ExperimentProvider>
);
};
export { createExperiment };

View File

@@ -0,0 +1,19 @@
import { useContext } from 'react';
import { ExperimentContext } from './context';
const useExperimentResult = () => {
const { result } = useContext(ExperimentContext);
return result?.payload;
};
const useSelectNode = () => {
const { selectNode } = useContext(ExperimentContext);
return selectNode;
};
const useSelectedNode = () => {
const { selectedNode } = useContext(ExperimentContext);
return selectedNode;
};
export { useExperimentResult, useSelectNode, useSelectedNode };

View File

@@ -0,0 +1,4 @@
export { createWorker } from './worker';
export { ExperimentProvider as Experminent } from './context';
export { createExperiment } from './create';
export { useExperimentResult } from './hooks';

View File

@@ -0,0 +1,23 @@
type WorkerFn = (...args: any[]) => any;
const createWorker = (fn: WorkerFn) => {
const run = async () => {
const startTime = performance.now();
try {
const result = await fn();
const endTime = performance.now();
const duration = endTime - startTime;
self.postMessage({ type: 'output', payload: result, duration });
} catch (error) {
self.postMessage({ type: 'error', payload: error });
}
};
self.addEventListener('message', (event) => {
const { type } = event.data;
if (type === 'run') {
run();
}
});
};
export { createWorker };

View File

@@ -1,100 +0,0 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { RunnerContext } from './context';
type BlockProps = {
worker: Worker;
action: string;
presenter?: React.FC<any>;
};
const id = (function*() {
let i = 0;
while (true) {
yield i++;
}
})();
const Block: React.FC<BlockProps> = ({
worker,
action,
presenter: Presenter,
}) => {
const currentId = useRef(id.next().value);
const { vars } = useContext(RunnerContext);
const [output, setOutput] = useState<unknown>();
const [error, setError] = useState<unknown>();
const [running, setRunning] = useState<boolean>();
const [duration, setDuration] = useState<number>();
const view = useMemo(() => {
if (error) {
return error.toString();
}
if (Presenter) {
return <Presenter output={output} />;
}
return JSON.stringify(output, null, 2);
}, [output, error, Presenter]);
const runBlock = useCallback(async () => {
setRunning(true);
setError(undefined);
setOutput(undefined);
try {
worker.postMessage({
type: 'run',
action,
vars,
id: currentId.current,
});
} catch (error) {
setError(error);
console.error(error);
}
setRunning(false);
}, [worker, vars, action]);
useEffect(() => {
const listener = (event: MessageEvent) => {
const { type, payload, id, duration } = event.data;
if (id !== currentId.current) {
return;
}
setDuration(duration);
setRunning(false);
if (type === 'output') {
setOutput(payload);
}
if (type === 'error') {
setError(payload);
console.error(payload);
}
};
worker.addEventListener('message', listener);
return () => {
worker.removeEventListener('message', listener);
};
}, [worker]);
return (
<div>
<button onClick={runBlock} disabled={running}>
Run
</button>
{duration && <div>Duration: {duration.toFixed(2)}ms</div>}
{running && <div>Running...</div>}
{view}
</div>
);
};
export { Block };

View File

@@ -1,42 +0,0 @@
import { createContext, useCallback, useMemo } from 'react';
type Vars = Record<string, unknown>;
type RunnerContextValue = {
vars: Vars;
run: (fn: (vars: Vars) => Promise<void>) => Promise<void>;
};
type RunnerProviderProps = {
vars?: Vars;
children: React.ReactNode;
};
const RunnerContext = createContext<RunnerContextValue>({
vars: {},
run: async () => {},
});
const RunnerProvider: React.FC<RunnerProviderProps> = ({
vars = {},
children,
}) => {
const currentVars = useMemo(() => vars, [vars]);
const run = useCallback(
async (fn: (vars: Vars) => Promise<void>) => {
const output = await fn(currentVars);
return output;
},
[currentVars],
);
return (
<RunnerContext.Provider value={{ vars, run }}>
{children}
</RunnerContext.Provider>
);
};
export type { Vars };
export { RunnerContext, RunnerProvider };

View File

@@ -1,3 +0,0 @@
export { RunnerProvider } from './context';
export { Block } from './block';
export { createWorker } from './worker';

View File

@@ -1,21 +0,0 @@
type WorkerFn = Record<string, (...args: any[]) => any>;
const createWorker = (fn: WorkerFn) => {
self.addEventListener('message', (event) => {
const { action, vars = {}, id } = event.data;
const run = async () => {
const startTime = performance.now();
try {
const result = await fn[action](vars);
const endTime = performance.now();
const duration = endTime - startTime;
self.postMessage({ type: 'output', payload: result, id, duration });
} catch (error) {
self.postMessage({ type: 'error', payload: error, id });
}
};
run();
});
};
export { createWorker };

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,3 +1,4 @@
import './main.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app.tsx';

View File

@@ -1,7 +0,0 @@
import { Block } from '../../features/runner'
import { worker } from './worker';
import { Presenter } from '../../presenters/graph';
# Hello World
<Block worker={worker} action="realistic" presenter={Presenter} />

View File

@@ -1,6 +0,0 @@
import './script.ts';
const worker = new Worker(new URL('./script.ts', import.meta.url), {
type: 'module',
});
export { worker };

View File

@@ -1,111 +1,70 @@
import { useMemo, useState } from 'react';
import { GraphCanvas } from 'reagraph';
import { ConvertedResult } from '../../utils/graph';
import { Plan } from './plan';
import { useExperimentResult } from '../../features/experiment';
import { convertResult } from '../../utils/graph';
import {
useSelectNode,
useSelectedNode,
} from '../../features/experiment/hooks';
type PresenterProps = {
output: ConvertedResult;
};
const Presenter: React.FC<PresenterProps> = ({ output }) => {
const [currentStep, setCurrentStep] = useState(0);
const Graph: React.FC = () => {
const data = useExperimentResult();
const selectedNode = useSelectedNode();
const selectNode = useSelectNode();
const output = useMemo(() => {
if (!data) {
return undefined;
}
return convertResult(data);
}, [data]);
const [visualize, setVisualize] = useState(false);
const [selectedNode, setSelectedNode] = useState<string | undefined>(
undefined,
);
const selectedPath = useMemo(() => {
if (!selectedNode) {
return [];
}
const result: string[] = [];
let current = output.result.nodes.find((n) => n.id === selectedNode);
let current = output?.result.nodes.find((n) => n.id === selectedNode.id);
while (current) {
result.push(current.id);
if (!current.parent) {
break;
}
current = output.result.nodes.find((n) => n.id === current?.parent);
current = output?.result.nodes.find((n) => n.id === current?.parent);
}
return result;
}, [selectedNode, output]);
const completed = useMemo(() => {
return (
output?.result?.completed
.map((c) => ({
id: c.id,
score: c.score,
}))
.sort((a, b) => b.score - a.score)
.slice(0, 10) || []
);
}, [output?.result?.completed]);
const maxStep = useMemo(
() => Math.max(...(output?.nodes?.map((n) => n.data?.exploreId) || [])),
[output],
);
const collapsedNodeIds = useMemo(
() =>
output?.nodes
?.filter((n) => n.data?.exploreId > currentStep)
.map((n) => n.id),
[output, currentStep],
);
if (!output) {
return null;
}
console.log(output);
return (
<>
Nodes count: {output.nodes.length}
<button onClick={() => setVisualize(!visualize)}>
{visualize ? 'Hide' : 'Show'} Visualize
</button>
{visualize && (
<>
<button onClick={() => setCurrentStep(currentStep - 1)}>Prev</button>
<input
type="range"
min={0}
max={maxStep}
value={currentStep}
onChange={(e) => setCurrentStep(parseInt(e.target.value))}
/>
<button onClick={() => setCurrentStep(currentStep + 1)}>Next</button>
</>
)}
{completed.map((c) => (
<div key={c.id} onClick={() => setSelectedNode(c.id)}>
{c.id} - {c.score}
</div>
))}
{selectedNode && <Plan id={selectedNode} output={output} />}
{visualize && (
<div style={{ position: 'relative', height: '70vh' }}>
<GraphCanvas
{...output}
collapsedNodeIds={collapsedNodeIds}
labelType="all"
layoutType="hierarchicalTd"
onNodeClick={(node) => {
if (node.id === selectedNode) {
setSelectedNode(undefined);
if (node.id === selectedNode?.id) {
selectNode(undefined);
return;
}
setSelectedNode(node.id);
const nextNode = data?.nodes.find((n) => n.id === node.id);
selectNode(nextNode);
}}
selections={selectedPath}
renderNode={({ size, opacity, node }) => {
let color = 'gray';
if (
node.data?.exploreId < currentStep &&
node.data?.exploreId > 0
) {
color = 'yellow';
}
if (node.data?.exploreId === currentStep) {
color = 'blue';
}
if (node.data?.deadEnd) {
color = 'red';
}
@@ -136,4 +95,4 @@ const Presenter: React.FC<PresenterProps> = ({ output }) => {
);
};
export { Presenter };
export { Graph };

View File

@@ -40,7 +40,7 @@ const Node = ({ node, output }: NodeProps) => {
if (node.type === 'travel') {
return (
<div>
{time} Travel: {node.location}
{time} Travel: {node.context.location}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { GraphNode } from '@bob-the-algorithm/core';
import { useMemo } from 'react';
import { convertResult } from '../../utils/graph';
import { format } from 'date-fns';
import { useExperimentResult } from '../../features/experiment';
import { useSelectNode } from '../../features/experiment/hooks';
type PlanProps = {
node: GraphNode;
};
type NodeProps = {
node: GraphNode;
};
const Node = ({ node }: NodeProps) => {
const selectNode = useSelectNode();
const data = useExperimentResult();
const planable = useMemo(() => {
return node.planable
? data?.planables.find((n) => n.id === node.planable)
: null;
}, [node, data]);
const time = useMemo(() => {
const start = new Date(node.time);
const end = new Date(start.getTime() + node.duration);
return (
<span>
{format(start, 'HH:mm')} - {format(end, 'HH:mm')}
</span>
);
}, [node.duration, node.time]);
if (planable) {
return (
<div onClick={() => selectNode(node)}>
{time} Planable: {planable!.id}
</div>
);
}
if (node.type === 'travel') {
return (
<div onClick={() => selectNode(node)}>
{time} Travel: {node.context.location}
</div>
);
}
return null;
};
const Plan: React.FC<PlanProps> = ({ node }) => {
const data = useExperimentResult();
const output = useMemo(() => (data ? convertResult(data) : null), [data]);
const nodes = useMemo(() => {
if (!output) {
return [];
}
const result: GraphNode[] = [];
let current = node;
if (!current) {
return [];
}
while (current) {
result.push(current);
if (!current.parent) {
break;
}
current = output.result.nodes.find((n) => n.id === current?.parent)!;
}
return result;
}, [output, node]);
if (!output) {
return null;
}
return (
<>
{nodes.map((n) => (
<Node key={n.id} node={n} />
))}
</>
);
};
export { Plan };

View File

@@ -0,0 +1,8 @@
const imports = import.meta.glob('../experiments/*/index.tsx');
const experiments = Object.entries(imports).map(([path, loader]) => {
const slug = path.replace('./experiments/', '').replace('/index.tsx', '');
return { slug, loader };
});
export { experiments };

View File

@@ -1,8 +0,0 @@
const imports = import.meta.glob('../pages/*/index.mdx');
const pages = Object.entries(imports).map(([path, loader]) => {
const slug = path.replace('./pages/', '').replace('/index.mdx', '');
return { slug, loader };
});
export { pages };