diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index 1733898..2adcb6b 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -2,36 +2,13 @@ import React from 'react'; import styles from './MonitoringPage.module.css'; import useProgramStore from "../../utils/programStore.ts"; import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList, RobotConnected } from './MonitoringPageComponents.tsx'; -import { nextPhase, useExperimentLogger, useStatusLogger, pauseExperiment, playExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts" +import { nextPhase, useExperimentLogger, useStatusLogger, pauseExperiment, playExperiment, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts" import { graphReducer, runProgramm } from '../VisProgPage/VisProg.tsx'; -import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx'; +import type { NormNodeData, NormNode } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx'; +import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx'; +import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx'; -// Stream message types are defined in MonitoringPageAPI as `ExperimentStreamData`. -// Types for reduced program items (output from node reducers): -export type ReducedPlanStep = { - id: string; - text?: string; - gesture?: { type: string; name?: string }; - goal?: string; -} & Record; - -export type ReducedPlan = { id: string; steps: ReducedPlanStep[] } | ""; - -export type ReducedGoal = { id: string; name: string; description?: string; can_fail?: boolean; plan?: ReducedPlan }; - -export type ReducedCondition = { - id: string; - keyword?: string; - emotion?: string; - object?: string; - name?: string; - description?: string; -} & Record; - -export type ReducedTrigger = { id: string; name: string; condition?: ReducedCondition | ""; plan?: ReducedPlan }; - -export type ReducedNorm = { id: string; label?: string; norm?: string; condition?: ReducedCondition | "" }; const MonitoringPage: React.FC = () => { @@ -77,8 +54,8 @@ const MonitoringPage: React.FC = () => { } else if (data.type === 'goal_update') { const payload = data as GoalUpdate; - const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as ReducedGoal[]; - const gIndex = currentPhaseGoals.findIndex((g: ReducedGoal) => g.id === payload.id); + const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[]; + const gIndex = currentPhaseGoals.findIndex((g: GoalNode) => g.id === payload.id); console.log(`${data.type} received, id : ${data.id}`) if (gIndex == -1) {console.log(`goal to update with id ${payload.id} not found in current phase ${phaseNames[phaseIndex]}`)} @@ -168,48 +145,16 @@ const resetExperiment = React.useCallback(async () => { const phaseId = phaseIds[phaseIndex]; - const goals = (getGoalsInPhase(phaseId) as ReducedGoal[]).map(g => ({ + const goals = (getGoalsInPhase(phaseId) as GoalNode[]).map(g => ({ ...g, - label: g.name, achieved: activeIds[g.id] ?? false, })); + - const triggers = (getTriggersInPhase(phaseId) as ReducedTrigger[]).map(t => ({ + const triggers = (getTriggersInPhase(phaseId) as TriggerNode[]).map(t => ({ ...t, - label: (() => { - - let prefix = ""; - if (t.condition && typeof t.condition !== "string" && "keyword" in t.condition && typeof t.condition.keyword === "string") { - prefix = `if keyword said: "${t.condition.keyword}"`; - } else if (t.condition && typeof t.condition !== "string" && "name" in t.condition && typeof t.condition.name === "string") { - prefix = `if LLM belief: ${t.condition.name}`; - } else { //fallback - prefix = t.name || "Trigger"; // use typed `name` as a reliable fallback - } - - - const stepLabels = (t.plan && typeof t.plan !== "string" ? t.plan.steps : []).map((step: ReducedPlanStep) => { - if ("text" in step && typeof step.text === "string") { - return `say: "${step.text}"`; - } - if ("gesture" in step && step.gesture) { - const g = step.gesture; - return `perform gesture: ${g.name || g.type}`; - } - if ("goal" in step && typeof step.goal === "string") { - return `perform LLM: ${step.goal}`; - } - return "Action"; // Fallback - }) || []; - - const planText = stepLabels.length > 0 - ? `➔ Do: ${stepLabels.join(", ")}` - : "➔ (No actions set)"; - - return `${prefix} ${planText}`; - })(), - isActive: activeIds[t.id] ?? false + achieved: activeIds[t.id] ?? false, })); const norms = (getNormsInPhase(phaseId) as NormNodeData[]) @@ -218,21 +163,10 @@ const resetExperiment = React.useCallback(async () => { ...n, label: n.norm, })); - const conditionalNorms = (getNormsInPhase(phaseId) as ReducedNorm[]) + const conditionalNorms = (getNormsInPhase(phaseId) as (NormNodeData &{id: string})[]) .filter(n => !!n.condition) // Only items with a condition .map(n => ({ ...n, - label: (() => { - let prefix = ""; - if (n.condition && typeof n.condition !== "string" && "keyword" in n.condition && typeof n.condition.keyword === "string") { - prefix = `if keyword said: "${n.condition.keyword}"`; - } else if (n.condition && typeof n.condition !== "string" && "name" in n.condition && typeof n.condition.name === "string") { - prefix = `if LLM belief: ${n.condition.name}`; - } - - - return `${prefix} ➔ Norm: ${n.norm}`; - })(), achieved: activeIds[n.id] ?? false })); diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx index 34bb04a..31e5b2d 100644 --- a/src/pages/MonitoringPage/MonitoringPageComponents.tsx +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -163,7 +163,7 @@ export const StatusList: React.FC = ({ borderRadius: '4px' }} > - {item.name || item.description || item.label || item.norm} + {item.name || item.norm} {isCurrentGoal && " (Current)"} diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 3a32b4f..8a0003c 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -84,7 +84,10 @@ filter: drop-shadow(0 0 0.25rem plum); } - +.node-inferred_belief { + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} .draggable-node { padding: 3px 10px; @@ -158,6 +161,14 @@ filter: drop-shadow(0 0 0.25rem plum); } +.draggable-node-inferred_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} + .planNoIterate { opacity: 0.5; font-style: italic; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 7d7aa1f..329053c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -165,6 +165,7 @@ export function runProgramm() { // when the program was sent to the backend successfully: useProgramStore.getState().setProgramState(structuredClone(program)); }).catch(() => console.log("Failed to send program to the backend.")); + console.log(program); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts index a04282c..aca415e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -43,3 +43,4 @@ export const noSelfConnections : HandleRule = } + diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 54f0241..a4285ec 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -52,15 +52,24 @@ import TriggerNode, { TriggerTooltip } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import InferredBeliefNode, { + InferredBeliefConnectionTarget, + InferredBeliefConnectionSource, + InferredBeliefDisconnectionTarget, + InferredBeliefDisconnectionSource, + InferredBeliefReduce, InferredBeliefTooltip +} from "./nodes/InferredBeliefNode"; +import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default"; import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, - BasicBeliefReduce, + BasicBeliefReduce +, BasicBeliefTooltip -} from "./nodes/BasicBeliefNode"; -import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; +} from "./nodes/BasicBeliefNode.tsx"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts"; /** * Registered node types in the visual programming system. @@ -76,6 +85,7 @@ export const NodeTypes = { goal: GoalNode, trigger: TriggerNode, basic_belief: BasicBeliefNode, + inferred_belief: InferredBeliefNode, }; /** @@ -91,6 +101,7 @@ export const NodeDefaults = { goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, basic_belief: BasicBeliefNodeDefaults, + inferred_belief: InferredBeliefNodeDefaults, }; @@ -108,6 +119,7 @@ export const NodeReduces = { goal: GoalReduce, trigger: TriggerReduce, basic_belief: BasicBeliefReduce, + inferred_belief: InferredBeliefReduce, } @@ -126,6 +138,7 @@ export const NodeConnections = { goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, basic_belief: BasicBeliefConnectionTarget, + inferred_belief: InferredBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -134,7 +147,8 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, - basic_belief: BasicBeliefConnectionSource + basic_belief: BasicBeliefConnectionSource, + inferred_belief: InferredBeliefConnectionSource, } } @@ -153,6 +167,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, basic_belief: BasicBeliefDisconnectionTarget, + inferred_belief: InferredBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -162,6 +177,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, basic_belief: BasicBeliefDisconnectionSource, + inferred_belief: InferredBeliefDisconnectionSource, }, } @@ -186,6 +202,7 @@ export const NodesInPhase = { end: () => false, phase: () => false, basic_belief: () => false, + inferred_belief: () => false, } /** @@ -199,4 +216,5 @@ export const NodeTooltips = { goal: GoalTooltip, trigger: TriggerTooltip, basic_belief: BasicBeliefTooltip, + inferred_belief: InferredBeliefTooltip, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts index 655aaaa..01f1cfa 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -1,4 +1,4 @@ -import type { BasicBeliefNodeData } from "./BasicBeliefNode"; +import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index bed642f..ed308b8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -3,13 +3,14 @@ import { Position, type Node, } from '@xyflow/react'; -import { Toolbar } from '../components/NodeComponents'; +import { Toolbar } from '../components/NodeComponents.tsx'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; -import useFlowStore from '../VisProgStores'; -import { TextField } from '../../../../components/TextField'; -import { MultilineTextField } from '../../../../components/MultilineTextField'; +import useFlowStore from '../VisProgStores.tsx'; +import { TextField } from '../../../../components/TextField.tsx'; +import { MultilineTextField } from '../../../../components/MultilineTextField.tsx'; +import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; /** * The default data structure for a BasicBelief node @@ -31,7 +32,7 @@ export type BasicBeliefNodeData = { }; // These are all the types a basic belief could be. -type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion +export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"}; type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"}; @@ -189,7 +190,7 @@ export default function BasicBeliefNode(props: NodeProps) { )} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts new file mode 100644 index 0000000..b92c5b2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts @@ -0,0 +1,63 @@ +import {getOutgoers, type Node} from '@xyflow/react'; +import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BasicBeliefReduce} from "./BasicBeliefNode.tsx"; +import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx"; + +export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) { + switch (beliefNode.type) { + case 'basic_belief': + return BasicBeliefReduce(beliefNode, nodes); + case 'inferred_belief': + return InferredBeliefReduce(beliefNode, nodes); + } +} + +export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> { + const { nodes } = useFlowStore.getState(); + const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief'); + if (!thisNode) return ruleResult.satisfied; + + const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief; + return (iBelief.left === connection.source || iBelief.right === connection.source) + ? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed") + : ruleResult.satisfied; +} +/** + * makes it impossible to connect Inferred belief nodes + * if the connection would create a cyclical connection between inferred beliefs + */ +export const noBeliefCycles: HandleRule = (connection, _): RuleResult => { + const {nodes, edges} = useFlowStore.getState(); + const defaultErrorMessage = "Cyclical connection exists between inferred beliefs"; + + /** + * recursively checks for cyclical connections between InferredBelief nodes + * + * to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check, + * the currentNodeId should be initialised with the id of the targetNode of the attempted connection. + * + * @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection + * @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node + * @returns {RuleResult} + */ + function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult { + const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges) + .filter(node => node.type === 'inferred_belief'); + + if (outgoingBeliefs.length === 0) return ruleResult.satisfied; + if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult + .notSatisfied(defaultErrorMessage); + + const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id)) + .find(result => !result.isSatisfied); + + return next + ? next + : ruleResult.satisfied; + } + + return connection.source === connection.target + ? ruleResult.notSatisfied(defaultErrorMessage) + : checkForCycle(connection.source, connection.target); +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts new file mode 100644 index 0000000..71f9f6b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts @@ -0,0 +1,16 @@ +import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx"; + + +/** + * Default data for this node + */ +export const InferredBeliefNodeDefaults: InferredBeliefNodeData = { + label: "Inferred Belief", + droppable: true, + inferredBelief: { + left: undefined, + operator: true, + right: undefined + }, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css new file mode 100644 index 0000000..2f9b7ae --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css @@ -0,0 +1,80 @@ +.operator-switch { + display: inline-flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + font-family: sans-serif; + /* Change this font-size to scale the whole component */ + font-size: 12px; +} + +/* hide the default checkbox */ +.operator-switch input { + display: none; +} + +/* The Track */ +.switch-visual { + position: relative; + /* height is now 3x the font size */ + height: 3em; + aspect-ratio: 1 / 2; + background-color: ButtonFace; + border-radius: 2em; + transition: 0.2s; +} + +/* The Knob */ +.switch-visual::after { + content: ""; + position: absolute; + top: 0.1em; + left: 0.1em; + width: 1em; + height: 1em; + background: Canvas; + border: 0.175em solid mediumpurple; + border-radius: 50%; + transition: transform 0.2s ease-in-out, border-color 0.2s; +} + +/* Labels */ +.switch-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 3em; /* Matches the track height */ + font-weight: 800; + color: Canvas; + line-height: 1.4; + padding: 0.2em 0; +} + +.operator-switch input:checked + .switch-visual::after { + /* Moves the slider down */ + transform: translateY(1.4em); +} + +/*change the colours to highlight the selected operator*/ +.operator-switch input:checked ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + :last-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } +} + +.operator-switch input:not(:checked) ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } + :last-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx new file mode 100644 index 0000000..be5d4ec --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -0,0 +1,176 @@ +import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; +import {useState} from "react"; +import styles from '../../VisProg.module.css'; +import {Toolbar} from '../components/NodeComponents.tsx'; +import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; +import switchStyles from './InferredBeliefNode.module.css'; + + +/** + * The default data structure for an InferredBelief node + */ +export type InferredBeliefNodeData = { + label: string; + droppable: boolean; + inferredBelief: InferredBelief; + hasReduce: boolean; +}; + +/** + * stores a boolean to represent the operator + * and a left and right BeliefNode (can be both an inferred and a basic belief) + * in the form of their corresponding id's + */ +export type InferredBelief = { + left: string | undefined, + operator: boolean, + right: string | undefined, +} + +export type InferredBeliefNode = Node; + +/** + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection + */ +export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId + && ['basic_belief', 'inferred_belief'].includes(node.type!))) + ) { + const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges); + switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){ + case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break; + case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break; + } + } +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined; + if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined; +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +export const InferredBeliefTooltip = ` + Combines two beliefs into a single belief using logical inference, + the node can be toggled between using "AND" and "OR" mode for inference`; +/** + * Defines how an InferredBelief node should be rendered + * @param {NodeProps} props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered InferredBeliefNode React element. (React.JSX.Element) + */ +export default function InferredBeliefNode(props: NodeProps) { + const data = props.data; + const { updateNodeData } = useFlowStore(); + // start of as an AND operator, true: "AND", false: "OR" + const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); + + // used to toggle operator + function onToggle() { + const newOperator = !enforceAllBeliefs; // compute the new value + setEnforceAllBeliefs(newOperator); + + updateNodeData(props.id, { + ...data, + inferredBelief: { + ...data.inferredBelief, + operator: enforceAllBeliefs, + } + }); + } + + return ( + <> + +
+ {/* The checkbox used to toggle the operator between 'AND' and 'OR' */} + + + + {/* outgoing connections */} + + + {/* incoming connections */} + + +
+ + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param {Node} node - The BasicBelief node to reduce. + * @param {Node[]} nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of BasicBeliefs. + */ +export function InferredBeliefReduce(node: Node, nodes: Node[]) { + const data = node.data as InferredBeliefNodeData; + const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left); + const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right); + + if (!leftBelief) { throw new Error("No Left belief found")} + if (!rightBelief) { throw new Error("No Right Belief found")} + + const result: Record = { + id: node.id, + left: BeliefGlobalReduce(leftBelief, nodes), + operator: data.inferredBelief.operator ? "AND" : "OR", + right: BeliefGlobalReduce(rightBelief, nodes), + }; + + return result +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 6cde46a..8ee5462 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; /** * The default data dot a phase node @@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps) { allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]) ]}/> ; @@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) { }; if (data.condition) { - const reducer = BasicBeliefReduce; // TODO: also add inferred. const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["condition"] = reducer(conditionNode, nodes) + result["condition"] = BeliefGlobalReduce(conditionNode, nodes) } return result } @@ -126,7 +125,7 @@ export const NormTooltip = ` export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; // If we got a belief connected, this is the condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) { data.condition = _sourceNodeId; } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 8b3378a..ca908c2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,8 +9,8 @@ import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleB import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import {PlanReduce, type Plan } from '../components/Plan'; -import PlanEditorDialog from '../components/PlanEditor'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import PlanEditorDialog from '../components/PlanEditor'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; import type { GoalNode } from './GoalNode.tsx'; import { defaultPlan } from '../components/Plan.default.ts'; import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; @@ -72,7 +72,7 @@ export default function TriggerNode(props: NodeProps) { id="TriggerBeliefs" style={{ left: '40%' }} rules={[ - allowOnlyConnectionsFromType(['basic_belief']), + allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]), ]} /> @@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param _nodes - The list of all nodes in the current flow graph. + * @param nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { const data = node.data as TriggerNodeData; const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined - const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : "" return { id: node.id, name: node.data.name, @@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) const otherNode = nodes.find((x) => x.id === _sourceNodeId) if (!otherNode) return; - if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { + if (otherNode.type === 'basic_belief'||'inferred_belief') { data.condition = _sourceNodeId; } diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts new file mode 100644 index 0000000..54cfa7f --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import {type Connection, getOutgoers, type Node} from '@xyflow/react'; +import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; +import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx"; +import { + BeliefGlobalReduce, noBeliefCycles, + noMatchingLeftRightBelief +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts"; +import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +describe('BeliefGlobalReduce', () => { + const nodes: Node[] = []; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to BasicBeliefReduce for basic_belief nodes', () => { + const spy = jest + .spyOn(BasicModule, 'BasicBeliefReduce') + .mockReturnValue('basic-result' as any); + + const node = { id: '1', type: 'basic_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('basic-result'); + }); + + it('delegates to InferredBeliefReduce for inferred_belief nodes', () => { + const spy = jest + .spyOn(InferredModule, 'InferredBeliefReduce') + .mockReturnValue('inferred-result' as any); + + const node = { id: '2', type: 'inferred_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('inferred-result'); + }); + + it('returns undefined for unknown node types', () => { + const node = { id: '3', type: 'other' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(result).toBeUndefined(); + expect(BasicBeliefReduce).not.toHaveBeenCalled(); + expect(InferredBeliefReduce).not.toHaveBeenCalled(); + }); +}); + +describe('noMatchingLeftRightBelief rule', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('is satisfied when target node is not an inferred belief', () => { + getStateSpy.mockReturnValue({ + nodes: [{ id: 't1', type: 'basic_belief' }], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is satisfied when inferred belief has no matching left/right', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 'a', + right: 'b', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 'c', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is NOT satisfied when source matches left input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); + + it('is NOT satisfied when source matches right input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's2', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); +}); + + +jest.mock('@xyflow/react', () => ({ + getOutgoers: jest.fn(), + getConnectedEdges: jest.fn(), // include if some tests require it +})); + +describe('noBeliefCycles rule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns notSatisfied when source === target', () => { + const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when there are no outgoing inferred beliefs', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [{ id: 'n1', type: 'inferred_belief' }], + edges: [], + } as any); + + (getOutgoers as jest.Mock).mockReturnValue([]); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); + + it('returns notSatisfied for direct cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'n1', type: 'inferred_belief' }, + { id: 'n2', type: 'inferred_belief' }, + ], + edges: [{ source: 'n2', target: 'n1' }], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }]; + return []; + }); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns notSatisfied for indirect cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'A' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [{ id: 'A', type: 'inferred_belief' }], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when no cycle exists in a multi-node graph', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 34872c9..a023769 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx new file mode 100644 index 0000000..d683b23 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type {Node, Edge} from '@xyflow/react'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { + type InferredBelief, + InferredBeliefConnectionTarget, + InferredBeliefDisconnectionTarget, + InferredBeliefReduce, +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; + +// helper functions +function inferredNode(overrides = {}): Node { + return { + id: 'i1', + type: 'inferred_belief', + position: {x: 0, y: 0}, + data: { + inferredBelief: { + left: undefined, + operator: true, + right: undefined, + }, + ...overrides, + }, + } as Node; +} + +describe('InferredBelief connection logic', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('sets left belief when connected on beliefLeft handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b1', type: 'basic_belief' }], + edges: [ + { + source: 'b1', + target: 'i1', + targetHandle: 'beliefLeft', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b1'); + + expect((node.data.inferredBelief as InferredBelief).left).toBe('b1'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('sets right belief when connected on beliefRight handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b2', type: 'basic_belief' }], + edges: [ + { + source: 'b2', + target: 'i1', + targetHandle: 'beliefRight', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b2'); + + expect((node.data.inferredBelief as InferredBelief).right).toBe('b2'); + }); + + it('ignores connections from unsupported node types', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'x', type: 'norm' }], + edges: [], + } as any); + + InferredBeliefConnectionTarget(node, 'x'); + + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('clears left or right belief on disconnection', () => { + const node = inferredNode({ + inferredBelief: { left: 'a', right: 'b', operator: true }, + }); + + InferredBeliefDisconnectionTarget(node, 'a'); + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + + InferredBeliefDisconnectionTarget(node, 'b'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); +}); + +describe('InferredBeliefReduce', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws if left belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'r' } as Node]) + ).toThrow('No Left belief found'); + }); + + it('throws if right belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'l' } as Node]) + ).toThrow('No Right Belief found'); + }); + +}); + +