Skip to content
Merged
12 changes: 12 additions & 0 deletions apps/azure/src/components/editor/InvestigationWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
computeInteractionEffects,
} from '@variscout/core';
import { detectEvidenceClusters } from '@variscout/core/findings';
import type { ColumnTypeMap } from '@variscout/core/findings';
import { detectColumns } from '@variscout/core/parser';
import { detectInvestigationPhase } from '@variscout/core/ai';
import { resolveMode, getStrategy } from '@variscout/core/strategy';
import { resolveCpkTarget } from '@variscout/core/capability';
Expand Down Expand Up @@ -179,6 +181,13 @@ export const InvestigationWorkspace: React.FC<InvestigationWorkspaceProps> = ({
() => (rawData.length > 0 ? Object.keys(rawData[0]) : undefined),
[rawData]
);
const columnTypes = useMemo<ColumnTypeMap>(() => {
if (rawData.length === 0) return {};
const det = detectColumns(rawData);
const map: ColumnTypeMap = {};
for (const c of det.columnAnalysis) map[c.name] = c.type;
return map;
}, [rawData]);
const highlightedFindingId = useFindingsStore(s => s.highlightedFindingId);
const causalLinks = useInvestigationStore(s => s.causalLinks);

Expand Down Expand Up @@ -812,6 +821,9 @@ export const InvestigationWorkspace: React.FC<InvestigationWorkspaceProps> = ({
problemCpk={0}
eventsPerWeek={0}
activeColumns={wallActiveColumns}
rows={rawData}
columnTypes={columnTypes}
outcomeColumn={outcome}
zoom={wallZoom}
pan={wallPan}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
Expand Down
13 changes: 13 additions & 0 deletions apps/pwa/src/components/views/InvestigationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type { FindingStatus, Question } from '@variscout/core';
import { detectInvestigationPhase } from '@variscout/core/ai';
import { getStrategy } from '@variscout/core/strategy';
import type { ResolvedMode } from '@variscout/core/strategy';
import { detectColumns } from '@variscout/core/parser';
import type { ColumnTypeMap } from '@variscout/core/findings';
import type { DrillStep } from '@variscout/hooks';
import { GripVertical } from 'lucide-react';
import { useWallLayoutStore, useProjectStore, useInvestigationStore } from '@variscout/stores';
Expand Down Expand Up @@ -95,12 +97,20 @@ const InvestigationView: React.FC<InvestigationViewProps> = ({
const setWallGroupByTributary = useWallLayoutStore(s => s.setGroupByTributary);
const processMap = useProjectStore(s => s.processContext?.processMap);
const rawData = useProjectStore(s => s.rawData);
const outcome = useProjectStore(s => s.outcome);
// Undefined when no rows are loaded so WallCanvas keeps the missing-column
// badge suppressed (rather than flagging every hub against an empty set).
const wallActiveColumns = useMemo<string[] | undefined>(
() => (rawData.length > 0 ? Object.keys(rawData[0]) : undefined),
[rawData]
);
const columnTypes = useMemo<ColumnTypeMap>(() => {
if (rawData.length === 0) return {};
const det = detectColumns(rawData);
const map: ColumnTypeMap = {};
for (const c of det.columnAnalysis) map[c.name] = c.type;
return map;
}, [rawData]);
const hubs = useInvestigationStore(s => s.hypotheses);
const wallFindings = useInvestigationStore(s => s.findings);
const wallQuestions = useInvestigationStore(s => s.questions);
Expand Down Expand Up @@ -314,6 +324,9 @@ const InvestigationView: React.FC<InvestigationViewProps> = ({
problemCpk={0}
eventsPerWeek={0}
activeColumns={wallActiveColumns}
rows={rawData}
columnTypes={columnTypes}
outcomeColumn={outcome}
zoom={wallZoom}
pan={wallPan}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/findings/__tests__/miniChart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { deriveMiniChartConfig } from '../miniChart';
import type { Hypothesis } from '../types';

const hub = (condition: Hypothesis['condition']): Hypothesis =>
({
id: 'h1',
name: 'test',
synthesis: '',
questionIds: [],
findingIds: [],
status: 'proposed',
investigationId: 'inv-1',
createdAt: 0,
updatedAt: 0,
deletedAt: null,
condition,
}) as Hypothesis;

describe('deriveMiniChartConfig', () => {
it('returns i-chart for numeric leaf factor', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }),
{ TEMP: 'numeric' },
'thickness'
);
expect(cfg).toEqual({ kind: 'i-chart', factor: 'TEMP', outcome: 'thickness' });
});

it('returns i-chart for date leaf factor (time-ordered)', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'shift_start', op: 'gte', value: 0 }),
{ shift_start: 'date' },
'thickness'
);
expect(cfg).toEqual({ kind: 'i-chart', factor: 'shift_start', outcome: 'thickness' });
});

it('returns boxplot for categorical leaf factor when outcome present', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }),
{ SUPPLIER: 'categorical' },
'thickness'
);
expect(cfg).toEqual({ kind: 'boxplot', factor: 'SUPPLIER', outcome: 'thickness' });
});

it('returns placeholder for categorical leaf when outcome missing', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }),
{ SUPPLIER: 'categorical' },
undefined
);
expect(cfg).toEqual({ kind: 'placeholder', factor: 'SUPPLIER', reason: 'no-outcome' });
});

it('returns placeholder for text factor', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'NOTES', op: 'eq', value: 'x' }),
{ NOTES: 'text' },
'thickness'
);
expect(cfg).toEqual({ kind: 'placeholder', factor: 'NOTES', reason: 'unsupported-type' });
});

it('returns placeholder when condition is undefined', () => {
const cfg = deriveMiniChartConfig(hub(undefined), {}, 'thickness');
expect(cfg).toEqual({ kind: 'placeholder', reason: 'no-condition' });
});

it('returns placeholder reason no-factor for empty AND children', () => {
const cfg = deriveMiniChartConfig(hub({ kind: 'and', children: [] }), {}, 'thickness');
expect(cfg).toEqual({ kind: 'placeholder', reason: 'no-factor' });
});

it('descends into AND branch and uses first leaf column', () => {
const cfg = deriveMiniChartConfig(
hub({
kind: 'and',
children: [
{ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' },
{ kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 },
],
}),
{ SUPPLIER: 'categorical', TEMP: 'numeric' },
'thickness'
);
expect(cfg.factor).toBe('SUPPLIER');
expect(cfg.kind).toBe('boxplot');
});

it('returns placeholder when factor column is unknown', () => {
const cfg = deriveMiniChartConfig(
hub({ kind: 'leaf', column: 'GHOST', op: 'eq', value: 'x' }),
{},
'thickness'
);
expect(cfg).toEqual({ kind: 'placeholder', factor: 'GHOST', reason: 'unknown-column' });
});
});
7 changes: 7 additions & 0 deletions packages/core/src/findings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,10 @@ export { computeFindingWindowDrift } from './drift';
export type { DriftResult } from './drift';
// WindowContext is already re-exported via `export * from './types'` above.
export { evidenceTypesForHypothesis, hasUnresolvedDisconfirmation } from './hypothesisEvidence';
export {
deriveMiniChartConfig,
type MiniChartConfig,
type MiniChartKind,
type MiniChartPlaceholderReason,
type ColumnTypeMap,
} from './miniChart';
111 changes: 111 additions & 0 deletions packages/core/src/findings/miniChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Pure helper: maps a Hypothesis.condition + column-type map + outcome column
* to a discriminated union describing which mini-chart to render inside a
* HypothesisCard on the Investigation Wall.
*
* This module has no UI imports and must stay pure TypeScript.
*/

import type { Hypothesis } from './types';
import type { HypothesisCondition } from './hypothesisCondition';
import type { ColumnAnalysis } from '../parser/types';

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export type MiniChartKind = 'i-chart' | 'boxplot' | 'placeholder';

export type MiniChartPlaceholderReason =
| 'no-condition'
| 'no-factor'
| 'unknown-column'
| 'unsupported-type'
| 'no-outcome';

export type MiniChartConfig =
| { kind: 'i-chart'; factor: string; outcome?: string }
| { kind: 'boxplot'; factor: string; outcome: string }
| { kind: 'placeholder'; factor?: string; reason: MiniChartPlaceholderReason };

/** Lookup table from column name → parser-detected column type. */
export type ColumnTypeMap = Record<string, ColumnAnalysis['type']>;

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Depth-first search for the first leaf column in a condition tree.
* Handles all four condition kinds: leaf, and, or, not.
*/
function firstLeafColumn(c: HypothesisCondition): string | undefined {
switch (c.kind) {
case 'leaf':
return c.column;
case 'and':
case 'or':
for (const child of c.children) {
const col = firstLeafColumn(child);
if (col !== undefined) return col;
}
return undefined;
case 'not':
return firstLeafColumn(c.child);
}
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Derive the mini-chart configuration for a hypothesis card.
*
* Decision rules (in priority order):
* 1. No condition → `placeholder` with reason `no-condition`.
* 2. No leaf column found in tree → `placeholder` with reason `no-factor`.
* 3. Column not in `columnTypes` map → `placeholder` with reason `unknown-column`.
* 4. `numeric` | `date` column → `i-chart` (time-ordered individual values).
* 5. `categorical` column + outcome present → `boxplot`.
* 6. `categorical` column + no outcome → `placeholder` with reason `no-outcome`.
* 7. Any other type (`text`, future additions) → `placeholder` with reason `unsupported-type`.
*
* @param hypothesis The hypothesis whose `condition` field drives chart selection.
* @param columnTypes Map from column name → parser-detected type.
* @param outcome The outcome column name (e.g. "thickness"). Pass `undefined`
* or `null` when no outcome has been detected for the dataset.
*/
export function deriveMiniChartConfig(
hypothesis: Hypothesis,
columnTypes: ColumnTypeMap,
outcome: string | undefined | null
): MiniChartConfig {
if (!hypothesis.condition) {
return { kind: 'placeholder', reason: 'no-condition' };
}

const factor = firstLeafColumn(hypothesis.condition);
if (factor === undefined) {
return { kind: 'placeholder', reason: 'no-factor' };
}

const colType = columnTypes[factor];
if (colType === undefined) {
return { kind: 'placeholder', factor, reason: 'unknown-column' };
}

if (colType === 'numeric' || colType === 'date') {
return { kind: 'i-chart', factor, outcome: outcome ?? undefined };
}

if (colType === 'categorical') {
if (outcome == null) {
return { kind: 'placeholder', factor, reason: 'no-outcome' };
}
return { kind: 'boxplot', factor, outcome };
}

// Covers 'text' and any future ColumnAnalysis types.
return { kind: 'placeholder', factor, reason: 'unsupported-type' };
}
68 changes: 68 additions & 0 deletions packages/hooks/src/__tests__/useMiniChartData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useMiniChartData } from '../useMiniChartData';
import type { Hypothesis } from '@variscout/core/findings';

const hub = (condition: Hypothesis['condition']): Hypothesis =>
({
id: 'h1',
name: '',
synthesis: '',
questionIds: [],
findingIds: [],
status: 'proposed',
investigationId: 'inv-1',
createdAt: 0,
updatedAt: 0,
deletedAt: null,
condition,
}) as Hypothesis;

describe('useMiniChartData', () => {
it('returns ichart values for numeric factor', () => {
const rows = [{ TEMP: 90 }, { TEMP: 95 }, { TEMP: 100 }];
const { result } = renderHook(() =>
useMiniChartData(
hub({ kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }),
rows,
{ TEMP: 'numeric' },
'thickness'
)
);
expect(result.current.kind).toBe('i-chart');
expect(result.current.values).toEqual([90, 95, 100]);
});

it('returns boxplot groups for categorical factor with outcome', () => {
const rows = [
{ SUPPLIER: 'A', thickness: 1.0 },
{ SUPPLIER: 'A', thickness: 1.1 },
{ SUPPLIER: 'B', thickness: 1.5 },
];
const { result } = renderHook(() =>
useMiniChartData(
hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }),
rows,
{ SUPPLIER: 'categorical', thickness: 'numeric' },
'thickness'
)
);
expect(result.current.kind).toBe('boxplot');
expect(result.current.groups).toHaveLength(2);
const a = result.current.groups!.find(g => g.category === 'A');
expect(a!.values).toEqual([1.0, 1.1]);
});

it('returns placeholder for categorical factor without outcome', () => {
const { result } = renderHook(() =>
useMiniChartData(
hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }),
[],
{ SUPPLIER: 'categorical' },
undefined
)
);
expect(result.current.kind).toBe('placeholder');
expect(result.current.reason).toBe('no-outcome');
});
});
3 changes: 3 additions & 0 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,6 @@ export {
useSessionCanvasFilters,
type UseSessionCanvasFiltersResult,
} from './useSessionCanvasFilters';

// Mini-chart data derivation (Wall Detective-pack)
export { useMiniChartData, type MiniChartData } from './useMiniChartData';
Loading