mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
chore: clean diagnostics and prepare for designer structural refactor (stub legacy useActiveStudy)
This commit is contained in:
391
src/components/experiments/designer/state/hashing.ts
Normal file
391
src/components/experiments/designer/state/hashing.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Hashing utilities for the Experiment Designer.
|
||||
*
|
||||
* Implements deterministic, canonical, incremental hashing per the redesign spec:
|
||||
* - Stable structural hashing for steps and actions
|
||||
* - Optional inclusion of parameter VALUES vs only parameter KEYS
|
||||
* - Incremental hash computation to avoid recomputing entire design on small changes
|
||||
* - Action signature hashing (schema/provenance sensitive) for drift detection
|
||||
*
|
||||
* Default behavior excludes parameter values from the design hash to reduce false-positive drift
|
||||
* caused by content edits (reproducibility concerns focus on structure + provenance).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentAction,
|
||||
ExperimentStep,
|
||||
ExecutionDescriptor,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Canonicalization */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type CanonicalPrimitive = string | number | boolean | null;
|
||||
type CanonicalValue =
|
||||
| CanonicalPrimitive
|
||||
| CanonicalValue[]
|
||||
| { [key: string]: CanonicalValue };
|
||||
|
||||
/**
|
||||
* Recursively canonicalize an unknown value:
|
||||
* - Removes undefined properties
|
||||
* - Sorts object keys
|
||||
* - Leaves arrays in existing (semantic) order
|
||||
*/
|
||||
function canonicalize(value: unknown): CanonicalValue {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => canonicalize(v));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, CanonicalValue> = {};
|
||||
Object.keys(obj)
|
||||
.filter((k) => obj[k] !== undefined)
|
||||
.sort()
|
||||
.forEach((k) => {
|
||||
out[k] = canonicalize(obj[k]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// Unsupported types (symbol, function, bigint) replaced with null
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hashing Primitives */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a lowercase hex string.
|
||||
*/
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]?.toString(16).padStart(2, "0");
|
||||
hex += b;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a UTF-8 string using Web Crypto if available, else Node's crypto.
|
||||
*/
|
||||
async function hashString(input: string): Promise<string> {
|
||||
// Prefer Web Crypto subtle (Edge/Browser compatible)
|
||||
if (typeof globalThis.crypto?.subtle?.digest === "function") {
|
||||
const enc = new TextEncoder().encode(input);
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", enc);
|
||||
return bufferToHex(digest);
|
||||
}
|
||||
|
||||
// Fallback to Node (should not execute in Edge runtime)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto: typeof import("crypto") = require("crypto");
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
} catch {
|
||||
throw new Error("No suitable crypto implementation available for hashing.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an object using canonical JSON serialization (no whitespace, sorted keys).
|
||||
*/
|
||||
export async function hashObject(obj: unknown): Promise<string> {
|
||||
const canonical = canonicalize(obj);
|
||||
return hashString(JSON.stringify(canonical));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Structural Projections */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignHashOptions {
|
||||
/**
|
||||
* Include parameter VALUES in hash rather than only parameter KEY sets.
|
||||
* Defaults to false (only parameter keys) to focus on structural reproducibility.
|
||||
*/
|
||||
includeParameterValues?: boolean;
|
||||
/**
|
||||
* Include action descriptive user-facing metadata (e.g. action.name) in hash.
|
||||
* Defaults to true - set false if wanting purely behavioral signature.
|
||||
*/
|
||||
includeActionNames?: boolean;
|
||||
/**
|
||||
* Include step descriptive fields (step.name, step.description).
|
||||
* Defaults to true.
|
||||
*/
|
||||
includeStepNames?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
||||
includeParameterValues: false,
|
||||
includeActionNames: true,
|
||||
includeStepNames: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Projection of an action for design hash purposes.
|
||||
*/
|
||||
function projectActionForDesign(
|
||||
action: ExperimentAction,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const parameterProjection = options.includeParameterValues
|
||||
? canonicalize(action.parameters)
|
||||
: Object.keys(action.parameters).sort();
|
||||
|
||||
const base: Record<string, unknown> = {
|
||||
id: action.id,
|
||||
type: action.type,
|
||||
source: {
|
||||
kind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: projectExecutionDescriptor(action.execution),
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
base.name = action.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function projectExecutionDescriptor(
|
||||
exec: ExecutionDescriptor,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
transport: exec.transport,
|
||||
retryable: exec.retryable ?? false,
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection of a step for design hash purposes.
|
||||
*/
|
||||
function projectStepForDesign(
|
||||
step: ExperimentStep,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
|
||||
if (options.includeStepNames) {
|
||||
base.name = step.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action Signature Hash (Schema / Provenance Drift) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ActionSignatureInput {
|
||||
type: string;
|
||||
category: string;
|
||||
parameterSchemaRaw?: unknown;
|
||||
execution?: ExecutionDescriptor;
|
||||
baseActionId?: string;
|
||||
pluginVersion?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash that uniquely identifies the structural/schema definition of an action definition.
|
||||
* Used for plugin drift detection: if signature changes, existing action instances require inspection.
|
||||
*/
|
||||
export async function computeActionSignature(
|
||||
def: ActionSignatureInput,
|
||||
): Promise<string> {
|
||||
const projection = {
|
||||
type: def.type,
|
||||
category: def.category,
|
||||
pluginId: def.pluginId ?? null,
|
||||
pluginVersion: def.pluginVersion ?? null,
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
return hashObject(projection);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Design Hash */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute a deterministic hash for the entire design (steps + actions) under given options.
|
||||
*/
|
||||
export async function computeDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Incremental Hashing */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface IncrementalHashMaps {
|
||||
actionHashes: Map<string, string>;
|
||||
stepHashes: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface IncrementalHashResult extends IncrementalHashMaps {
|
||||
designHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or reuse action/step hashes to avoid re-hashing unchanged branches.
|
||||
*/
|
||||
export async function computeIncrementalDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
previous?: IncrementalHashMaps,
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<IncrementalHashResult> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const actionHashes = new Map<string, string>();
|
||||
const stepHashes = new Map<string, string>();
|
||||
|
||||
// First compute per-action hashes
|
||||
for (const step of steps) {
|
||||
for (const action of step.actions) {
|
||||
const existing = previous?.actionHashes.get(action.id);
|
||||
if (existing) {
|
||||
// Simple heuristic: if shallow structural keys unchanged, reuse
|
||||
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
||||
actionHashes.set(action.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedAction = projectActionForDesign(action, options);
|
||||
const h = await hashObject(projectedAction);
|
||||
actionHashes.set(action.id, h);
|
||||
}
|
||||
}
|
||||
|
||||
// Then compute step hashes (including ordered list of action hashes)
|
||||
for (const step of steps) {
|
||||
const existing = previous?.stepHashes.get(step.id);
|
||||
if (existing) {
|
||||
stepHashes.set(step.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedStep = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
};
|
||||
const h = await hashObject(projectedStep);
|
||||
stepHashes.set(step.id, h);
|
||||
}
|
||||
|
||||
// Aggregate design hash from ordered step hashes + minimal meta
|
||||
const orderedStepHashes = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => stepHashes.get(s.id));
|
||||
|
||||
const designHash = await hashObject({
|
||||
steps: orderedStepHashes,
|
||||
count: steps.length,
|
||||
});
|
||||
|
||||
return { designHash, actionHashes, stepHashes };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience helper to check if design hash matches a known validated hash.
|
||||
*/
|
||||
export function isDesignHashValidated(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
return Boolean(currentHash && validatedHash && currentHash === validatedHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine structural drift given last validated snapshot hash and current.
|
||||
*/
|
||||
export function hasStructuralDrift(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
if (!validatedHash) return false;
|
||||
if (!currentHash) return false;
|
||||
return currentHash !== validatedHash;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const Hashing = {
|
||||
canonicalize,
|
||||
hashObject,
|
||||
computeDesignHash,
|
||||
computeIncrementalDesignHash,
|
||||
computeActionSignature,
|
||||
isDesignHashValidated,
|
||||
hasStructuralDrift,
|
||||
};
|
||||
export default Hashing;
|
||||
Reference in New Issue
Block a user