feat: improved plugin structure

This commit is contained in:
Morten Olsen
2023-09-06 22:58:51 +02:00
parent 89c0271fc5
commit a81afc9221
16 changed files with 366 additions and 282 deletions

View File

@@ -25,5 +25,8 @@
"build": "tsc --build configs/tsconfig.libs.json" "build": "tsc --build configs/tsconfig.libs.json"
}, },
"types": "./dist/cjs/types/index.d.ts", "types": "./dist/cjs/types/index.d.ts",
"version": "0.1.8" "version": "0.1.8",
"dependencies": {
"@sinclair/typebox": "^0.31.14"
}
} }

View File

@@ -1,87 +1,103 @@
import { Attributes, GraphNode } from '../types/node'; import { GraphNode } from '../types/node';
import { Planable } from '../types/planable'; import { Planable } from '../types/planable';
import { PluginAttributes, Plugins } from '../types/plugin'; import { PluginAttributes, PluginContext, Plugins } from '../types/plugin';
import { expandNode } from './expand-node'; import { expandNode } from './expand-node';
type CalulationOptions<TPlugins extends Plugins> = { type CalulationOptions<TPlugins extends Plugins> = {
location: string;
time: number;
planables: Planable<PluginAttributes<TPlugins>>[];
plugins: TPlugins; plugins: TPlugins;
};
type CalulationResult<TPlugins extends Plugins> = {
root: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>;
nodes: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>[];
completed: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>[];
planables: Planable<PluginAttributes<TPlugins>>[];
};
type RunOptions<TPlugins extends Plugins> = {
start: number;
context: PluginContext<TPlugins>;
planables: Planable<PluginAttributes<TPlugins>>[];
heuristic?: (result: any) => boolean; heuristic?: (result: any) => boolean;
onUpdated?: (result: any) => void;
}; };
type CalulationResult<TAttributes extends Attributes> = { class Bob<TPlugins extends Plugins> {
root: GraphNode<TAttributes>; #options: CalulationOptions<TPlugins>;
nodes: GraphNode<TAttributes>[]; #id: number = 0;
completed: GraphNode<TAttributes>[];
planables: Planable<TAttributes>[];
};
const idGen = () => { constructor(options: CalulationOptions<TPlugins>) {
let id = 0; this.#options = options;
return () => {
id += 1;
return id.toString();
};
};
const calulation = async <TPlugins extends Plugins>({
location,
time,
planables,
plugins,
heuristic,
onUpdated,
}: CalulationOptions<TPlugins>): Promise<
CalulationResult<PluginAttributes<TPlugins>>
> => {
const generateId = idGen();
let exploreId = 1;
const root: GraphNode<PluginAttributes<TPlugins>> = {
id: generateId(),
type: 'root',
score: 0,
parent: null,
duration: 0,
time,
location,
exploreId: 0,
remaining: planables,
};
const nodes: GraphNode<PluginAttributes<TPlugins>>[] = [root];
const leafNodes: GraphNode<PluginAttributes<TPlugins>>[] = [root];
const completed: GraphNode<PluginAttributes<TPlugins>>[] = [];
const popHighestScore = () => {
const highestScore = Math.max(...leafNodes.map((n) => n.score));
const highestScoreNode = leafNodes.find((n) => n.score === highestScore);
if (!highestScoreNode) {
throw new Error('No highest score node');
}
leafNodes.splice(leafNodes.indexOf(highestScoreNode), 1);
return highestScoreNode;
};
while (leafNodes.length > 0) {
const node = popHighestScore();
node.exploreId = exploreId++;
const expanded = await expandNode({ node, generateId, plugins });
nodes.push(...expanded);
completed.push(...expanded.filter((n) => n.remaining.length === 0));
leafNodes.push(...expanded.filter((n) => n.remaining.length > 0));
if (heuristic && heuristic({ root, nodes, completed, planables })) {
break;
}
if (onUpdated) {
onUpdated({ root, nodes, completed, planables });
}
} }
return { root, nodes, completed, planables }; #getNextId = (): string => {
}; return (this.#id++).toString();
};
public run = async ({
planables,
start,
context,
heuristic,
}: RunOptions<TPlugins>): Promise<CalulationResult<TPlugins>> => {
const { plugins } = this.#options;
let exploreId = 1;
const root: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
> = {
id: this.#getNextId(),
context,
type: 'root',
score: 0,
parent: null,
duration: 0,
time: start,
exploreId: 0,
remaining: planables,
};
const nodes: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [root];
const leafNodes: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [root];
const completed: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [];
const popHighestScore = () => {
const highestScore = Math.max(...leafNodes.map((n) => n.score));
const highestScoreNode = leafNodes.find((n) => n.score === highestScore);
if (!highestScoreNode) {
throw new Error('No highest score node');
}
leafNodes.splice(leafNodes.indexOf(highestScoreNode), 1);
return highestScoreNode;
};
while (leafNodes.length > 0) {
const node = popHighestScore();
node.exploreId = exploreId++;
const expanded = await expandNode({
node,
generateId: this.#getNextId,
plugins,
});
nodes.push(...expanded);
completed.push(...expanded.filter((n) => n.remaining.length === 0));
leafNodes.push(...expanded.filter((n) => n.remaining.length > 0));
if (heuristic && heuristic({ root, nodes, completed, planables })) {
break;
}
}
return { root, nodes, completed, planables };
};
}
export type { CalulationOptions, CalulationResult }; export type { CalulationOptions, CalulationResult };
export { calulation }; export { Bob };

View File

@@ -1,18 +1,26 @@
import { Attributes, GraphNode } from '../types/node'; import { Attributes, GraphNode } from '../types/node';
import { Plugin } from '../types/plugin'; import { Plugins } from '../types/plugin';
import { hasImpossible } from './is-impossible'; import { hasImpossible } from './is-impossible';
type ExpandOptions<TAttributes extends Attributes> = { type ExpandOptions<
node: GraphNode<TAttributes>; TAttributes extends Attributes,
TContext extends Attributes,
> = {
node: GraphNode<TAttributes, TContext>;
generateId: () => string; generateId: () => string;
plugins: Plugin[]; plugins: Plugins;
}; };
const expandNode = async <TAttributes extends Attributes>({ const expandNode = async <
TAttributes extends Attributes,
TContext extends Attributes,
>({
node, node,
generateId, generateId,
plugins, plugins,
}: ExpandOptions<TAttributes>): Promise<GraphNode<TAttributes>[]> => { }: ExpandOptions<TAttributes, TContext>): Promise<
GraphNode<TAttributes, TContext>[]
> => {
const isImpossible = hasImpossible({ node }); const isImpossible = hasImpossible({ node });
if (isImpossible) { if (isImpossible) {
@@ -21,7 +29,7 @@ const expandNode = async <TAttributes extends Attributes>({
} }
const metaNodes = await Promise.all( const metaNodes = await Promise.all(
plugins.map(async (plugin) => { Object.values(plugins).map(async (plugin) => {
if (!plugin.getMetaNodes) { if (!plugin.getMetaNodes) {
return []; return [];
} }
@@ -39,7 +47,7 @@ const expandNode = async <TAttributes extends Attributes>({
); );
const planables = node.remaining.filter((planable) => { const planables = node.remaining.filter((planable) => {
const hasNonPlanable = plugins.some( const hasNonPlanable = Object.values(plugins).some(
(plugin) => plugin.isPlanable && !plugin.isPlanable(node, planable), (plugin) => plugin.isPlanable && !plugin.isPlanable(node, planable),
); );
return !hasNonPlanable; return !hasNonPlanable;

View File

@@ -1,5 +1,5 @@
export type { GraphNode } from './types/node'; export type { GraphNode } from './types/node';
export type { Planable } from './types/planable'; export type { Planable } from './types/planable';
export { expandNode } from './algorithm/expand-node'; export { expandNode } from './algorithm/expand-node';
export { calulation, type CalulationResult } from './algorithm/calulation'; export { Bob, type CalulationResult } from './algorithm/calulation';
export { plugins } from './plugins/index'; export { plugins } from './plugins/index';

View File

@@ -0,0 +1,20 @@
import type { TSchema, Static } from '@sinclair/typebox';
import type { Plugin } from '../types/plugin';
const createPlugin = <
TAttribuesSchema extends TSchema,
TContextSchema extends TSchema,
>(
attributes: TAttribuesSchema,
context: TContextSchema,
plugin: Omit<
Plugin<Static<TAttribuesSchema>, Static<TContextSchema>>,
'attributes' | 'context'
>,
): Plugin<Static<TAttribuesSchema>, Static<TContextSchema>> => ({
...plugin,
attributes,
context,
});
export { createPlugin };

View File

@@ -1,9 +1,8 @@
import { Attributes } from '../types/node';
import { Plugin } from '../types/plugin'; import { Plugin } from '../types/plugin';
import { transport } from './transport'; import { transport } from './transport';
const plugins = { const plugins = {
transport, transport,
} satisfies Record<string, (...args: any[]) => Plugin<Attributes>>; } satisfies Record<string, (...args: any[]) => Plugin>;
export { plugins }; export { plugins };

View File

@@ -1,5 +1,5 @@
import { GraphNode } from '../types/node'; import { Type } from '@sinclair/typebox';
import { Plugin } from '../types/plugin'; import { createPlugin } from './create';
type GetTravelTime = (from: string, to: string) => Promise<number>; type GetTravelTime = (from: string, to: string) => Promise<number>;
@@ -7,58 +7,66 @@ type TransportOptions = {
getTravelTime: GetTravelTime; getTravelTime: GetTravelTime;
}; };
type TransportAttributes = { const transport = ({ getTravelTime }: TransportOptions) =>
locations?: string[]; createPlugin(
}; Type.Object({
locations: Type.Optional(Type.Array(Type.String())),
}),
Type.Object({
location: Type.String(),
}),
{
getMetaNodes: async (node) => {
const locations =
(node.type !== 'travel' &&
[
...new Set(
node.remaining
.map((planable) => planable.attributes?.locations)
.flat(),
),
]
.filter((location) => location !== node.context.location)
.filter(Boolean)
.map((l) => l!)) ||
[];
const transport = ({ const travelNodes = await Promise.all(
getTravelTime, locations.map(async (location) => {
}: TransportOptions): Plugin<TransportAttributes> => ({ const travelTime = await getTravelTime(
getMetaNodes: async (node) => { node.context.location,
const locations = location,
(node.type !== 'travel' && );
[ return {
...new Set( ...node,
node.remaining type: 'travel' as const,
.map((planable) => planable.attributes?.locations) context: {
.flat(), ...node.context,
), location,
] },
.filter((location) => location !== node.location) planable: undefined,
.filter(Boolean) location,
.map((l) => l!)) || exploreId: 0,
[]; score: node.score - 10,
time: node.time + node.duration,
duration: travelTime,
parent: node.id,
};
}),
);
const travelNodes = await Promise.all( return travelNodes;
locations.map<Promise<GraphNode<TransportAttributes>>>( },
async (location) => { isPlanable: (node, planable) => {
const travelTime = await getTravelTime(node.location, location); if (
return { planable.attributes?.locations &&
...node, !planable.attributes?.locations.includes(node.context.location)
type: 'travel', ) {
planable: undefined, return false;
location, }
exploreId: 0, return true;
score: node.score - 20, },
time: node.time + node.duration, },
duration: travelTime, );
parent: node.id,
};
},
),
);
return travelNodes;
},
isPlanable: (node, planable) => {
if (
planable.attributes?.locations &&
!planable.attributes?.locations.includes(node.location)
) {
return false;
}
return true;
},
});
export { transport }; export { transport };

View File

@@ -2,11 +2,14 @@ import type { Planable } from './planable';
type Attributes = any; type Attributes = any;
type GraphNode<TAttributes extends Attributes = Attributes> = { type GraphNode<
TAttributes extends Attributes = Attributes,
TContext extends Attributes = Attributes,
> = {
id: string; id: string;
type: 'root' | 'planable' | 'travel'; type: 'root' | 'planable' | 'travel';
score: number; score: number;
location: string; context: TContext;
parent: string | null; parent: string | null;
time: number; time: number;
exploreId: number; exploreId: number;

View File

@@ -11,7 +11,6 @@ type Planable<TAttributes extends Attributes = Attributes> = {
}; };
attributes: TAttributes; attributes: TAttributes;
required?: boolean; required?: boolean;
locations?: string[];
}; };
export type { Planable }; export type { Planable };

View File

@@ -1,25 +1,31 @@
import { Attributes, GraphNode } from './node'; import { GraphNode } from './node';
import { Planable } from './planable'; import { Planable } from './planable';
type Plugin<TAttributes extends Attributes = Attributes> = { type Plugin<TAttributes = any, TContext = any> = {
context: any;
attributes: any;
getMetaNodes?: ( getMetaNodes?: (
node: GraphNode<TAttributes>, node: GraphNode<TAttributes, TContext>,
) => Promise<GraphNode<TAttributes>[]>; ) => Promise<GraphNode<TAttributes, TContext>[]>;
isImpossible?: (node: GraphNode<TAttributes>) => Promise<boolean>; isImpossible?: (node: GraphNode<TAttributes, TContext>) => Promise<boolean>;
isPlanable?: ( isPlanable?: (
node: GraphNode<TAttributes>, node: GraphNode<TAttributes, TContext>,
planable: Planable<TAttributes>, planable: Planable<TAttributes>,
) => boolean; ) => boolean;
}; };
type Plugins = Plugin[]; type Plugins = Record<string, Plugin>;
type PluginAttributes<TPlugins extends Plugins> = { type PluginAttributes<TPlugins extends Plugins> = {
[K in keyof TPlugins]: TPlugins[K] extends Plugin<infer TAttributes> [K in keyof TPlugins]: TPlugins[K] extends Plugin<infer TAttributes, any>
? TAttributes extends Attributes
? TAttributes ? TAttributes
: never
: never; : never;
}[number]; }[keyof TPlugins];
export type { Plugin, Plugins, PluginAttributes }; type PluginContext<TPlugins extends Plugins> = {
[K in keyof TPlugins]: TPlugins[K] extends Plugin<any, infer TContext>
? TContext
: never;
}[keyof TPlugins];
export type { Plugin, Plugins, PluginAttributes, PluginContext };

View File

@@ -1,8 +1,12 @@
{ {
"extends": "./tsconfig.base.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2022", "DOM"], "lib": [
"ES2022",
"DOM"
],
"target": "ES2022", "target": "ES2022",
"module": "ESNext" "module": "ES2022",
"moduleResolution": "node"
} }
} }

View File

@@ -1,8 +1,7 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
</head> </head>

View File

@@ -14,7 +14,7 @@ type BlockProps = {
presenter?: React.FC<any>; presenter?: React.FC<any>;
}; };
const id = (function* () { const id = (function*() {
let i = 0; let i = 0;
while (true) { while (true) {
yield i++; yield i++;
@@ -57,6 +57,7 @@ const Block: React.FC<BlockProps> = ({
}); });
} catch (error) { } catch (error) {
setError(error); setError(error);
console.error(error);
} }
setRunning(false); setRunning(false);
@@ -75,6 +76,7 @@ const Block: React.FC<BlockProps> = ({
} }
if (type === 'error') { if (type === 'error') {
setError(payload); setError(payload);
console.error(payload);
} }
}; };
worker.addEventListener('message', listener); worker.addEventListener('message', listener);

View File

@@ -1,4 +1,4 @@
import { calulation, plugins } from '@bob-the-algorithm/core'; import { Bob, plugins } from '@bob-the-algorithm/core';
import { createWorker } from '../../features/runner/worker'; import { createWorker } from '../../features/runner/worker';
import { convertResult } from '../../utils/graph'; import { convertResult } from '../../utils/graph';
@@ -6,141 +6,149 @@ const MIN = 1000 * 60;
const HOUR = 1000 * 60 * 60; const HOUR = 1000 * 60 * 60;
const getTravelTime = async () => 30 * MIN; const getTravelTime = async () => 30 * MIN;
const transport = plugins.transport({
getTravelTime,
});
const realistic = async () => { const realistic = async () => {
const result = await calulation({ try {
location: 'home', const bob = new Bob({
time: 0, plugins: { transport },
heuristic: ({ completed }) => completed.length >= 3, });
plugins: [ const result = await bob.run({
plugins.transport({ context: {
getTravelTime, location: 'home',
}),
],
planables: [
{
id: `Brush teeth`,
duration: 2 * MIN,
start: {
min: 7 * HOUR,
max: 8 * HOUR,
},
attributes: {
locations: ['home'],
},
score: 1,
}, },
{ start: 0,
id: 'Drop off kids', heuristic: ({ completed }) => completed.length >= 3,
duration: 30 * MIN, planables: [
attributes: { {
locations: ['daycare'], id: `Brush teeth`,
duration: 2 * MIN,
start: {
min: 7 * HOUR,
max: 8 * HOUR,
},
attributes: {
locations: ['home'],
},
score: 1,
}, },
score: 1, {
start: { id: 'Drop off kids',
min: 7 * HOUR, duration: 30 * MIN,
max: 9 * HOUR, attributes: {
locations: ['daycare'],
},
score: 1,
start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
}, },
}, {
{ id: 'Pickup the kids',
id: 'Pickup the kids', duration: 30 * MIN,
duration: 30 * MIN, attributes: {
attributes: { locations: ['daycare'],
locations: ['daycare'], },
score: 1,
start: {
min: 15 * HOUR,
max: 15.5 * HOUR,
},
}, },
score: 1, {
start: { id: `Eat breakfast`,
min: 15 * HOUR, duration: 15 * MIN,
max: 15.5 * HOUR, start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
attributes: {
locations: ['home'],
},
score: 1,
}, },
}, {
{ id: 'Do work',
id: `Eat breakfast`, duration: 1 * HOUR,
duration: 15 * MIN, count: 5,
start: { attributes: {
min: 7 * HOUR, locations: ['work'],
max: 9 * HOUR, },
score: 10,
start: {
min: 8 * HOUR,
max: 18 * HOUR,
},
}, },
attributes: { {
locations: ['home'], id: 'Read book',
duration: 0.5 * HOUR,
attributes: {
locations: ['home', 'work'],
},
score: 3,
count: 2,
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
}, },
score: 1, {
}, id: 'Meditate',
{ duration: 10 * MIN,
id: 'Do work', score: 1,
duration: 1 * HOUR, attributes: {},
count: 5, start: {
attributes: { min: 8 * HOUR,
locations: ['work'], max: 22 * HOUR,
},
}, },
score: 10, {
start: { id: 'Meeting 1',
min: 8 * HOUR, duration: 1 * HOUR,
max: 18 * HOUR, attributes: {
locations: ['work', 'work'],
},
score: 10,
start: {
min: 10 * HOUR,
max: 10 * HOUR,
},
}, },
}, {
{ id: 'Meeting 2',
id: 'Read book', duration: 1 * HOUR,
duration: 0.5 * HOUR, attributes: {
attributes: { locations: ['work', 'work'],
locations: ['home', 'work'], },
score: 10,
start: {
min: 12 * HOUR,
max: 12 * HOUR,
},
}, },
score: 3, {
count: 2, id: 'Play playstation',
start: { duration: 1 * HOUR,
min: 8 * HOUR, attributes: {
max: 22 * HOUR, locations: ['home'],
},
score: 10,
start: {
min: 16 * HOUR,
max: 24 * HOUR,
},
}, },
}, ],
{ });
id: 'Meditate', return convertResult(result);
duration: 10 * MIN, } catch (e) {
score: 1, console.error(e);
attributes: {}, throw e;
start: { }
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meeting 1',
duration: 1 * HOUR,
attributes: {
locations: ['work', 'work'],
},
score: 10,
start: {
min: 10 * HOUR,
max: 10 * HOUR,
},
},
{
id: 'Meeting 2',
duration: 1 * HOUR,
attributes: {
locations: ['work', 'work'],
},
score: 10,
start: {
min: 12 * HOUR,
max: 12 * HOUR,
},
},
{
id: 'Play playstation',
duration: 1 * HOUR,
attributes: {
locations: ['home'],
},
score: 10,
start: {
min: 16 * HOUR,
max: 24 * HOUR,
},
},
],
});
return convertResult(result);
}; };
createWorker({ createWorker({

View File

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

8
pnpm-lock.yaml generated
View File

@@ -31,6 +31,10 @@ importers:
version: 20.5.9 version: 20.5.9
packages/algorithm: packages/algorithm:
dependencies:
'@sinclair/typebox':
specifier: ^0.31.14
version: 0.31.14
devDependencies: devDependencies:
'@bob-the-algorithm/config': '@bob-the-algorithm/config':
specifier: workspace:^ specifier: workspace:^
@@ -1234,6 +1238,10 @@ packages:
rollup: 3.28.1 rollup: 3.28.1
dev: true dev: true
/@sinclair/typebox@0.31.14:
resolution: {integrity: sha512-2spk0ie6J/4r+nwb55OtBXUn5cZLF9S98fopIjuutBVoq8yLRNh+h8QvMkCjMu5gWBMnnZ/PUSXeHE3xGBPKLQ==}
dev: false
/@swc/core-darwin-arm64@1.3.82: /@swc/core-darwin-arm64@1.3.82:
resolution: {integrity: sha512-JfsyDW34gVKD3uE0OUpUqYvAD3yseEaicnFP6pB292THtLJb0IKBBnK50vV/RzEJtc1bR3g1kNfxo2PeurZTrA==} resolution: {integrity: sha512-JfsyDW34gVKD3uE0OUpUqYvAD3yseEaicnFP6pB292THtLJb0IKBBnK50vV/RzEJtc1bR3g1kNfxo2PeurZTrA==}
engines: {node: '>=10'} engines: {node: '>=10'}