From 381882fa602ad42d3139e3ee5ca23f33dcfe862d Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Wed, 22 Jul 2026 20:55:10 -0700 Subject: [PATCH] feat(core): add kinetic envelope conformance runner --- packages/conformance-tests/package.json | 2 +- .../src/kinetic-envelope-conformance.test.ts | 207 ++++++++++++++++++ packages/core/src/schemas/index.ts | 1 + .../src/schemas/kinetic-envelope.schema.ts | 87 ++++++++ packages/core/src/schemas/policy.schema.ts | 11 + packages/core/src/types/index.ts | 1 + packages/core/src/types/kinetic-envelope.ts | 79 +++++++ packages/core/src/types/policy.ts | 11 + 8 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 packages/conformance-tests/src/kinetic-envelope-conformance.test.ts create mode 100644 packages/core/src/schemas/kinetic-envelope.schema.ts create mode 100644 packages/core/src/types/kinetic-envelope.ts diff --git a/packages/conformance-tests/package.json b/packages/conformance-tests/package.json index 4e83277..1cf8310 100644 --- a/packages/conformance-tests/package.json +++ b/packages/conformance-tests/package.json @@ -7,7 +7,7 @@ "build": "echo 'no build needed'", "test": "vitest run", "test:watch": "vitest watch", - "test:fixtures": "vitest run src/canonical-fixtures-conformance.test.ts src/a2a-fixtures-conformance.test.ts src/security-iot-fixtures-conformance.test.ts src/economy-fixtures-conformance.test.ts src/autogen-interop-conformance.test.ts src/agentskill-authz-fixtures-conformance.test.ts src/action-ref-explainability-conformance.test.ts src/payment-governance-fixtures-conformance.test.ts src/agent-commerce-governance-conformance.test.ts src/physical-work-market-conformance.test.ts src/mission-authority-reference-slice-conformance.test.ts src/mission-authority-reference-gateway-conformance.test.ts src/mission-authority-release-lane-evidence-conformance.test.ts src/physical-ai-runtime-safety-fixtures-conformance.test.ts src/post-quantum-crypto-agility-conformance.test.ts src/humanoid-profile-conformance.test.ts src/humanoid-warehouse-pilot-conformance.test.ts src/eu-ai-act-conformity-pack-conformance.test.ts src/humanoid-multivendor-fleet-conformance.test.ts src/open-rmf-handoff-policy-receipts-conformance.test.ts src/moveit-manipulation-policy-receipts-conformance.test.ts src/nav2-navigation-policy-receipts-conformance.test.ts src/px4-offboard-policy-receipts-conformance.test.ts src/lerobot-policy-actuation-receipts-conformance.test.ts src/solar-field-operations-policy-receipts-conformance.test.ts src/industrial-cell-safety-pack-conformance.test.ts src/industrial-humanoid-shipyard-safety-pack-conformance.test.ts src/factory-action-demo-conformance.test.ts src/sint-industrial-pack-conformance.test.ts src/regulated-agent-runtime-conformance.test.ts src/regulated-consent-extensions-conformance.test.ts src/autonomy-supervisor-conformance.test.ts", + "test:fixtures": "vitest run src/canonical-fixtures-conformance.test.ts src/a2a-fixtures-conformance.test.ts src/security-iot-fixtures-conformance.test.ts src/economy-fixtures-conformance.test.ts src/autogen-interop-conformance.test.ts src/agentskill-authz-fixtures-conformance.test.ts src/action-ref-explainability-conformance.test.ts src/payment-governance-fixtures-conformance.test.ts src/agent-commerce-governance-conformance.test.ts src/physical-work-market-conformance.test.ts src/mission-authority-reference-slice-conformance.test.ts src/mission-authority-reference-gateway-conformance.test.ts src/mission-authority-release-lane-evidence-conformance.test.ts src/physical-ai-runtime-safety-fixtures-conformance.test.ts src/kinetic-envelope-conformance.test.ts src/post-quantum-crypto-agility-conformance.test.ts src/humanoid-profile-conformance.test.ts src/humanoid-warehouse-pilot-conformance.test.ts src/eu-ai-act-conformity-pack-conformance.test.ts src/humanoid-multivendor-fleet-conformance.test.ts src/open-rmf-handoff-policy-receipts-conformance.test.ts src/moveit-manipulation-policy-receipts-conformance.test.ts src/nav2-navigation-policy-receipts-conformance.test.ts src/px4-offboard-policy-receipts-conformance.test.ts src/lerobot-policy-actuation-receipts-conformance.test.ts src/solar-field-operations-policy-receipts-conformance.test.ts src/industrial-cell-safety-pack-conformance.test.ts src/industrial-humanoid-shipyard-safety-pack-conformance.test.ts src/factory-action-demo-conformance.test.ts src/sint-industrial-pack-conformance.test.ts src/regulated-agent-runtime-conformance.test.ts src/regulated-consent-extensions-conformance.test.ts src/autonomy-supervisor-conformance.test.ts", "test:physical-ai-runtime": "vitest run src/physical-ai-runtime-safety-fixtures-conformance.test.ts", "test:factory-action": "vitest run src/factory-action-demo-conformance.test.ts", "test:ros2-loop": "vitest run src/ros2-control-loop-latency.test.ts" diff --git a/packages/conformance-tests/src/kinetic-envelope-conformance.test.ts b/packages/conformance-tests/src/kinetic-envelope-conformance.test.ts new file mode 100644 index 0000000..147889d --- /dev/null +++ b/packages/conformance-tests/src/kinetic-envelope-conformance.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ApprovalTier, + KINETIC_CAPACITY_EXCEEDED, + kineticEnvelopeResultSchema, + kineticReceiptSchema, + type KineticEnvelopeResult, + type KineticTightenedConstraints, + type SupervisionLevel, +} from "@pshkv/core"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const FIXTURE_PATH = resolve( + ROOT, + "spec/kinetic-envelope/conformance/ros2-reference-v0.1.json", +); + +const PROFILE = { + vMaxMps: 1.5, + fMaxN: 150, + tauMaxNm: 40, + omegaMaxRps: 2.0, + eBase: 1.0, + dSafeM: 1.0, + mAllow: 0.5, + metacognitiveFloor: 0.33, + assistedFloor: 0.15, +} as const; + +type DecisionAction = "allow" | "deny" | "escalate"; +type MarginBand = "safe" | "approaching" | "over"; + +interface KineticFixture { + readonly profile: "ros2-reference-v0.1"; + readonly note: string; + readonly vectors: readonly KineticVector[]; +} + +interface KineticVector { + readonly id: string; + readonly note: string; + readonly request: { + readonly resource: string; + readonly action: string; + readonly physicalContext: { + readonly currentVelocityMps?: number; + readonly currentForceNewtons?: number; + readonly currentTorqueNm?: number; + readonly currentAngularVelocityRps?: number; + readonly humanDetected?: boolean; + readonly nearestObstacleMeters?: number; + readonly trajectoryNovel?: boolean; + }; + }; + readonly expect: { + readonly referenceMargin: number; + readonly marginBand: MarginBand; + readonly supervision: SupervisionLevel; + readonly decision: DecisionAction; + readonly policyCode?: typeof KINETIC_CAPACITY_EXCEEDED; + readonly capacityKnown: boolean; + readonly tightenedAtMost?: KineticTightenedConstraints; + }; +} + +function loadFixture(): KineticFixture { + return JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as KineticFixture; +} + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function round3(n: number): number { + return Math.round(n * 1000) / 1000; +} + +function supervisionForMargin(margin: number, capacityKnown: boolean): SupervisionLevel { + let supervision: SupervisionLevel; + if (margin <= 0) supervision = "silent_veto"; + else if (margin >= PROFILE.mAllow) supervision = "stable"; + else if (margin >= PROFILE.metacognitiveFloor) supervision = "metacognitive"; + else if (margin >= PROFILE.assistedFloor) supervision = "assisted"; + else supervision = "regulated"; + + if (!capacityKnown && (supervision === "stable" || supervision === "metacognitive")) { + return "assisted"; + } + return supervision; +} + +function decisionForSupervision(supervision: SupervisionLevel): DecisionAction { + if (supervision === "stable") return "allow"; + if (supervision === "silent_veto") return "deny"; + return "escalate"; +} + +function marginBandFor(margin: number): MarginBand { + if (margin <= 0) return "over"; + if (margin >= PROFILE.mAllow) return "safe"; + return "approaching"; +} + +function computeReferenceEnvelope(vector: KineticVector): KineticEnvelopeResult { + const ctx = vector.request.physicalContext; + const autonomyDemand = Math.max( + (ctx.currentVelocityMps ?? 0) / PROFILE.vMaxMps, + (ctx.currentForceNewtons ?? 0) / PROFILE.fMaxN, + (ctx.currentTorqueNm ?? 0) / PROFILE.tauMaxNm, + Math.abs(ctx.currentAngularVelocityRps ?? 0) / PROFILE.omegaMaxRps, + ); + const proximity = clamp((ctx.nearestObstacleMeters ?? 0) / PROFILE.dSafeM, 0, 1); + const human = ctx.humanDetected ? 0.5 : 1; + const novelty = ctx.trajectoryNovel ? 0.6 : 1; + const environmentalCapacity = PROFILE.eBase * proximity * human * novelty; + const capacityKnown = ctx.trajectoryNovel !== true && ctx.nearestObstacleMeters !== undefined; + const margin = + environmentalCapacity > 0 ? 1 - autonomyDemand / environmentalCapacity : Number.NEGATIVE_INFINITY; + const supervision = supervisionForMargin(margin, capacityKnown); + const tightenedConstraints: KineticTightenedConstraints = {}; + + if (ctx.currentVelocityMps !== undefined && margin > 0) { + tightenedConstraints.maxVelocityMps = round3(PROFILE.vMaxMps * clamp(margin, 0, 1)); + } + if (ctx.currentForceNewtons !== undefined && margin > 0) { + tightenedConstraints.maxForceNewtons = round3(PROFILE.fMaxN * clamp(margin, 0, 1)); + } + if (ctx.currentTorqueNm !== undefined && margin > 0) { + tightenedConstraints.maxTorqueNm = round3(PROFILE.tauMaxNm * clamp(margin, 0, 1)); + } + + return { + autonomyDemand: round3(autonomyDemand), + environmentalCapacity: round3(environmentalCapacity), + margin: round3(margin), + capacityKnown, + tightenedConstraints, + supervision, + rationale: [vector.note], + }; +} + +function expectTightenedAtMost( + actual: KineticTightenedConstraints, + ceilings: KineticTightenedConstraints | undefined, +): void { + if (!ceilings) return; + for (const [key, ceiling] of Object.entries(ceilings)) { + if (typeof ceiling !== "number") continue; + const actualValue = actual[key as keyof KineticTightenedConstraints]; + expect(typeof actualValue, key).toBe("number"); + expect(actualValue as number, key).toBeLessThanOrEqual(ceiling); + } +} + +describe("Kinetic envelope conformance vectors", () => { + const fixture = loadFixture(); + + it("loads the ROS2 reference fixture with the unknown-capacity clamp case", () => { + expect(fixture.profile).toBe("ros2-reference-v0.1"); + expect(fixture.vectors.map((v) => v.id)).toEqual([ + "V1-cmd_vel-safe", + "V2-cmd_vel-near-margin", + "V3-human-near", + "V4-force-over-limit", + "V5-obstacle-proximity", + "V6-torque-over-limit", + "V7-novel-workspace-unknown-capacity", + ]); + }); + + for (const vector of fixture.vectors) { + it(`${vector.id}: produces the expected decision surface`, () => { + const result = computeReferenceEnvelope(vector); + const parsed = kineticEnvelopeResultSchema.parse(result); + const decision = decisionForSupervision(parsed.supervision); + const policyCode = + parsed.supervision === "silent_veto" ? KINETIC_CAPACITY_EXCEEDED : undefined; + + expect(parsed.margin).toBeCloseTo(vector.expect.referenceMargin, 3); + expect(marginBandFor(parsed.margin)).toBe(vector.expect.marginBand); + expect(parsed.supervision).toBe(vector.expect.supervision); + expect(decision).toBe(vector.expect.decision); + expect(policyCode).toBe(vector.expect.policyCode); + expect(parsed.capacityKnown).toBe(vector.expect.capacityKnown); + expectTightenedAtMost(parsed.tightenedConstraints, vector.expect.tightenedAtMost); + + const receipt = kineticReceiptSchema.parse({ + requestId: vector.id, + autonomyDemand: parsed.autonomyDemand, + environmentalCapacity: parsed.environmentalCapacity, + margin: parsed.margin, + capacityKnown: parsed.capacityKnown, + supervision: parsed.supervision, + finalTier: decision === "allow" ? ApprovalTier.T1_PREPARE : ApprovalTier.T2_ACT, + deautomated: decision === "escalate", + vetoed: decision === "deny", + policyCode, + rationale: parsed.rationale, + }); + expect(receipt.vetoed).toBe(vector.expect.decision === "deny"); + }); + } +}); diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 700ffbc..4b4864d 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -2,3 +2,4 @@ export * from "./capability-token.schema.js"; export * from "./policy.schema.js"; export * from "./engine.schema.js"; export * from "./mission-authority.schema.js"; +export * from "./kinetic-envelope.schema.js"; diff --git a/packages/core/src/schemas/kinetic-envelope.schema.ts b/packages/core/src/schemas/kinetic-envelope.schema.ts new file mode 100644 index 0000000..2d71d6b --- /dev/null +++ b/packages/core/src/schemas/kinetic-envelope.schema.ts @@ -0,0 +1,87 @@ +/** + * SINT Protocol — Zod validation schemas for kinetic envelopes. + * + * These schemas validate the opt-in physics-aware supervision surface. They do + * not make any autonomy-demand / environmental-capacity formula normative. + * + * @module @sint/core/schemas/kinetic-envelope + */ + +import { z } from "zod"; +import { ApprovalTier, KINETIC_CAPACITY_EXCEEDED } from "../types/index.js"; + +export const supervisionLevelSchema = z.enum([ + "stable", + "metacognitive", + "assisted", + "regulated", + "silent_veto", +]); + +export const kineticJointLimitSchema = z.object({ + joint: z.string().min(1).max(128), + maxPositionRad: z.number().optional(), + maxVelocityRps: z.number().min(0).optional(), + maxEffortNm: z.number().min(0).optional(), +}).strict(); + +export const kineticTightenedConstraintsSchema = z.object({ + maxVelocityMps: z.number().min(0).optional(), + maxForceNewtons: z.number().min(0).optional(), + maxTorqueNm: z.number().min(0).optional(), + maxJerkMps3: z.number().min(0).optional(), + maxAngularVelocityRps: z.number().min(0).optional(), + jointLimits: z.array(kineticJointLimitSchema).optional(), + proximityMinMeters: z.number().min(0).optional(), +}).strict(); + +export const kineticEnvelopeResultSchema = z.object({ + autonomyDemand: z.number().min(0), + environmentalCapacity: z.number().min(0), + margin: z.number(), + capacityKnown: z.boolean(), + tightenedConstraints: kineticTightenedConstraintsSchema, + supervision: supervisionLevelSchema, + rationale: z.array(z.string().min(1)), +}).strict(); + +export const kineticReceiptSchema = z.object({ + requestId: z.string().min(1), + autonomyDemand: z.number().min(0), + environmentalCapacity: z.number().min(0), + margin: z.number(), + capacityKnown: z.boolean(), + supervision: supervisionLevelSchema, + finalTier: z.nativeEnum(ApprovalTier), + deautomated: z.boolean(), + vetoed: z.boolean(), + policyCode: z.literal(KINETIC_CAPACITY_EXCEEDED).optional(), + rationale: z.array(z.string().min(1)), +}).strict().superRefine((receipt, ctx) => { + if (receipt.vetoed && receipt.policyCode !== KINETIC_CAPACITY_EXCEEDED) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "vetoed kinetic receipts must carry KINETIC_CAPACITY_EXCEEDED", + path: ["policyCode"], + }); + } + + if (!receipt.vetoed && receipt.policyCode !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "policyCode is only valid on vetoed kinetic receipts", + path: ["policyCode"], + }); + } + + if (receipt.vetoed && receipt.deautomated) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "vetoed kinetic receipts are not deautomated escalations", + path: ["deautomated"], + }); + } +}); + +export type ValidatedKineticEnvelopeResult = z.infer; +export type ValidatedKineticReceipt = z.infer; diff --git a/packages/core/src/schemas/policy.schema.ts b/packages/core/src/schemas/policy.schema.ts index 9babaa3..a4d8d08 100644 --- a/packages/core/src/schemas/policy.schema.ts +++ b/packages/core/src/schemas/policy.schema.ts @@ -15,6 +15,17 @@ export const physicalContextSchema = z.object({ humanDetected: z.boolean().optional(), currentForceNewtons: z.number().min(0).optional(), currentVelocityMps: z.number().min(0).optional(), + currentTorqueNm: z.number().min(0).optional(), + currentJerkMps3: z.number().min(0).optional(), + currentAngularVelocityRps: z.number().min(0).optional(), + nearestObstacleMeters: z.number().min(0).optional(), + trajectoryNovel: z.boolean().optional(), + jointStates: z.array(z.object({ + joint: z.string().min(1).max(128), + positionRad: z.number().optional(), + velocityRps: z.number().optional(), + effortNm: z.number().optional(), + }).strict()).optional(), currentPosition: z.object({ x: z.number(), y: z.number(), diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 8498ad7..1cbdbe3 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -7,3 +7,4 @@ export * from "./engine.js"; export * from "./compliance.js"; export * from "./protocol.js"; export * from "./mission-authority.js"; +export * from "./kinetic-envelope.js"; diff --git a/packages/core/src/types/kinetic-envelope.ts b/packages/core/src/types/kinetic-envelope.ts new file mode 100644 index 0000000..7c21fe4 --- /dev/null +++ b/packages/core/src/types/kinetic-envelope.ts @@ -0,0 +1,79 @@ +/** + * SINT Protocol — kinetic envelope types. + * + * Kinetic envelopes are opt-in runtime checks for physics-aware supervision. + * They may tighten executable constraints and raise supervision, but they never + * widen capability-token authority. + * + * @module @sint/core/types/kinetic-envelope + */ + +import type { ApprovalTier, SintRequest } from "./policy.js"; + +/** Distinct deny code for physics vetoes where autonomy demand exceeds capacity. */ +export const KINETIC_CAPACITY_EXCEEDED = "KINETIC_CAPACITY_EXCEEDED" as const; + +/** Supervision level computed by a kinetic-envelope provider. */ +export type SupervisionLevel = + | "stable" + | "metacognitive" + | "assisted" + | "regulated" + | "silent_veto"; + +/** Joint-specific tightening emitted by a kinetic-envelope provider. */ +export interface KineticJointLimit { + readonly joint: string; + readonly maxPositionRad?: number; + readonly maxVelocityRps?: number; + readonly maxEffortNm?: number; +} + +/** Constraint overrides that must be min-merged with token constraints. */ +export interface KineticTightenedConstraints { + readonly maxVelocityMps?: number; + readonly maxForceNewtons?: number; + readonly maxTorqueNm?: number; + readonly maxJerkMps3?: number; + readonly maxAngularVelocityRps?: number; + readonly jointLimits?: readonly KineticJointLimit[]; + readonly proximityMinMeters?: number; +} + +/** Result returned by an opt-in kinetic-envelope provider. */ +export interface KineticEnvelopeResult { + /** Dimensionless autonomy demand, A >= 0. */ + readonly autonomyDemand: number; + /** Dimensionless environmental capacity, E >= 0. */ + readonly environmentalCapacity: number; + /** Normalized headroom, conventionally 1 - A/E when E > 0. */ + readonly margin: number; + /** False when capacity is estimated or required sensing is missing. */ + readonly capacityKnown: boolean; + /** Tighten-only executable constraints. */ + readonly tightenedConstraints: KineticTightenedConstraints; + /** Supervision level required by the kinetic envelope. */ + readonly supervision: SupervisionLevel; + /** Human-readable explanation fragments for audit/operator surfaces. */ + readonly rationale: readonly string[]; +} + +/** Opt-in plugin surface. No plugin registered means no gateway behavior change. */ +export interface KineticEnvelopePlugin { + computeEnvelope(request: SintRequest): Promise; +} + +/** Evidence receipt shape for the kinetic-envelope decision. */ +export interface KineticReceipt { + readonly requestId: string; + readonly autonomyDemand: number; + readonly environmentalCapacity: number; + readonly margin: number; + readonly capacityKnown: boolean; + readonly supervision: SupervisionLevel; + readonly finalTier: ApprovalTier; + readonly deautomated: boolean; + readonly vetoed: boolean; + readonly policyCode?: typeof KINETIC_CAPACITY_EXCEEDED; + readonly rationale: readonly string[]; +} diff --git a/packages/core/src/types/policy.ts b/packages/core/src/types/policy.ts index 634cc31..d1d2d82 100644 --- a/packages/core/src/types/policy.ts +++ b/packages/core/src/types/policy.ts @@ -215,6 +215,17 @@ export interface SintRequest { readonly humanDetected?: boolean; readonly currentForceNewtons?: number; readonly currentVelocityMps?: number; + readonly currentTorqueNm?: number; + readonly currentJerkMps3?: number; + readonly currentAngularVelocityRps?: number; + readonly nearestObstacleMeters?: number; + readonly trajectoryNovel?: boolean; + readonly jointStates?: ReadonlyArray<{ + readonly joint: string; + readonly positionRad?: number; + readonly velocityRps?: number; + readonly effortNm?: number; + }>; readonly currentPosition?: { x: number; y: number; z: number }; readonly currentHeadingDeg?: number; readonly localizationConfidence?: number;