Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Extracted from `systemfsoftware/systemfsoftware` (core); package history came wi

| Package | Version | Distributed |
| -------------------------- | ------- | ----------- |
| `@systemfsoftware/discern` | 1.1.2 | yes |
| `@systemfsoftware/discern` | 1.1.3 | yes |

## Dependencies on core

Expand Down
6 changes: 3 additions & 3 deletions packages/discern/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@systemfsoftware/discern",
"license": "Apache-2.0",
"version": "1.1.2",
"version": "1.1.3",
"author": "Ryan Lee <drdgvhbh@gmail.com>",
"repository": {
"type": "git",
Expand Down Expand Up @@ -93,8 +93,8 @@
"provenance": true
},
"inlinedDependencies": {
"@effect/platform-node": "4.0.7",
"@effect/platform-node-shared": "4.0.7",
"@effect/platform-node": "4.1.0",
"@effect/platform-node-shared": "4.1.0",
"@jridgewell/sourcemap-codec": "1.6.0",
"@systemfsoftware/vitest": "3.0.0",
"chai": "6.3.0",
Expand Down
10 changes: 5 additions & 5 deletions packages/discern/src/ContentAddress.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Schema } from 'effect'
import type * as Decision from 'effect/ai/Decision'
import { dual } from 'effect/Function'
import { decisionDefinition } from './DecisionDefinition.schema.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'

/**
* The canonical digest of a value, used to key recordings, caches, and decision
Expand Down Expand Up @@ -69,7 +69,7 @@ export const hash = (value: Hashable): ContentAddress =>
ContentAddress.make(digestOf(typeof value === 'string' ? value : canonicalOf(value, true)))

/** Identity of a decision *definition* — instructions and criteria, not its id. */
export const decisionFingerprint = (decision: Decision.Any): ContentAddress =>
export const decisionFingerprint = (decision: SupportedDecision): ContentAddress =>
ContentAddress.make(`df_${digestOf(canonicalOf(decisionDefinition(decision), false))}`)

/**
Expand All @@ -79,11 +79,11 @@ export const decisionFingerprint = (decision: Decision.Any): ContentAddress =>
* different inputs, without collisions.
*/
export const observationAddress: {
(state: Schema.Json): (decision: Decision.Any) => ContentAddress
(decision: Decision.Any, state: Schema.Json): ContentAddress
(state: Schema.Json): (decision: SupportedDecision) => ContentAddress
(decision: SupportedDecision, state: Schema.Json): ContentAddress
} = dual(
2,
(decision: Decision.Any, state: Schema.Json): ContentAddress =>
(decision: SupportedDecision, state: Schema.Json): ContentAddress =>
ContentAddress.make(
`o_${digestOf(`${canonicalOf(decisionDefinition(decision), false)}|${canonicalOf(state, true)}`)}`,
),
Expand Down
3 changes: 2 additions & 1 deletion packages/discern/src/DecisionDefinition.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Match, Schema } from 'effect'
import type * as Decision from 'effect/ai/Decision'
import type { SupportedDecision } from './SupportedDecision.schema.js'

const descriptionTextOf = (description: Decision.Description): string =>
typeof description === 'string' ? description : JSON.stringify(description)
Expand Down Expand Up @@ -30,7 +31,7 @@ export const DecisionDefinition = Schema.Union([
]).pipe(Schema.toTaggedUnion('_tag'))
export type DecisionDefinition = typeof DecisionDefinition.Type

export const decisionDefinition = (decision: Decision.Any): DecisionDefinition =>
export const decisionDefinition = (decision: SupportedDecision): DecisionDefinition =>
Match.value(decision).pipe(
Match.tag(
'Classify',
Expand Down
12 changes: 7 additions & 5 deletions packages/discern/src/DiscernError.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class InvalidThresholdError
* A span names its ends in the wrong order. `low` and `high` carry the ends as
* numbers or level names, so the refusal names both ends the caller wrote.
*/
export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError>()(
export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError, { readonly brand: unique symbol }>()(
'InvalidRangeError',
{
range: Schema.String,
Expand All @@ -59,10 +59,12 @@ export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError>()(
},
) {}

export class CalibrationValuesRefused extends Schema.TaggedError<CalibrationValuesRefused>()(
'CalibrationValuesRefused',
{},
) {
export class CalibrationValuesRefused
extends Schema.TaggedError<CalibrationValuesRefused, { readonly brand: unique symbol }>()(
'CalibrationValuesRefused',
{},
)
{
override get message(): string {
return 'Calibration needs at least one candidate value; the list was empty'
}
Expand Down
8 changes: 8 additions & 0 deletions packages/discern/src/SupportedDecision.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type * as Decision from 'effect/ai/Decision'

/**
* The decision kinds Discern routes on: classify, rate and probability. Effect
* 4.1.0 added `Decision.Locate`, which answers with an image bounding box, not a
* verdict a pattern can match, so Discern does not accept it.
*/
export type SupportedDecision = Exclude<Decision.Any, Decision.Locate>
5 changes: 3 additions & 2 deletions packages/discern/src/budget-provider.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import * as Effect from 'effect/Effect'
import { AdmitBudgetCharge, admitBudgetCharge } from './admit-budget-charge.workflow.js'
import { type Budget, chargeBudget, spent } from './budget.handle.js'
import type { Provider } from './decision-model.blueprint.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'

export interface BudgetRequest {
readonly options: DecisionModel.ProviderOptions
readonly options: DecisionModel.ProviderOptions<SupportedDecision>
readonly inner: Provider
readonly budget: Budget
}

export type BudgetRead = (typeof AdmitBudgetCharge)['Encoded'] & {
readonly options: DecisionModel.ProviderOptions
readonly options: DecisionModel.ProviderOptions<SupportedDecision>
readonly inner: Provider
readonly budget: Budget
}
Expand Down
6 changes: 3 additions & 3 deletions packages/discern/src/cache-provider.cell.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Sandwich } from '@systemfsoftware/effect-cell-types'
import * as AiError from 'effect/ai/AiError'
import type * as Decision from 'effect/ai/Decision'
import type * as DecisionModel from 'effect/ai/DecisionModel'
import * as Effect from 'effect/Effect'
import type { ObservationSplit, Provider, StoredHitCheck } from './decision-model.blueprint.js'
import type { ProviderAnswer } from './Observation.schema.js'
import { CurrentRegion } from './region.service.js'
import { SelectObservationSource, selectObservationSource } from './select-observation-source.workflow.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'

export interface CacheRequest {
readonly options: DecisionModel.ProviderOptions
readonly options: DecisionModel.ProviderOptions<SupportedDecision>
readonly inner: Provider
readonly split: ObservationSplit
readonly validate: StoredHitCheck
Expand All @@ -21,7 +21,7 @@ export interface CacheRequest {

export type CacheRead = (typeof SelectObservationSource)['Encoded'] & {
readonly hits: Readonly<Record<string, ProviderAnswer>>
readonly missingDecisions: Readonly<Record<string, Decision.Any>>
readonly missingDecisions: Readonly<Record<string, SupportedDecision>>
readonly state: DecisionModel.ProviderOptions['state']
readonly inner: Provider
readonly validate: StoredHitCheck
Expand Down
35 changes: 18 additions & 17 deletions packages/discern/src/decision-model.blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Observation, Observations, ProviderAnswer } from './Observation.schema.
import { CurrentRegion } from './region.service.js'
import { replayObservations } from './replay-provider.cell.js'
import type { RecordingMissing } from './select-observation-source.workflow.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'

export const DiscernMethod = {
unavailable: 'unavailable',
Expand Down Expand Up @@ -78,20 +79,20 @@ export interface ObservationSplit {
/** The recorded answers, keyed by decision id. */
readonly hits: Readonly<Record<string, ProviderAnswer>>
/** The decisions with no recorded answer, keyed by decision id. */
readonly missingDecisions: Readonly<Record<string, Decision.Any>>
readonly missingDecisions: Readonly<Record<string, SupportedDecision>>
}

interface Placement {
readonly id: string
readonly decision: Decision.Any
readonly decision: SupportedDecision
readonly recorded: Observation | undefined
}

const isRecorded = (placement: Placement): placement is Placement & { readonly recorded: Observation } =>
placement.recorded !== undefined

const placementsOf = (
decisions: Readonly<Record<string, Decision.Any>>,
decisions: Readonly<Record<string, SupportedDecision>>,
state: Schema.Json,
lookup: ObservationStore | undefined,
): Effect.Effect<ReadonlyArray<Placement>> =>
Expand All @@ -107,7 +108,7 @@ const placementsOf = (
})))

const splitObservations = (
decisions: Readonly<Record<string, Decision.Any>>,
decisions: Readonly<Record<string, SupportedDecision>>,
state: Schema.Json,
lookup: ObservationStore | undefined,
): Effect.Effect<ObservationSplit> =>
Expand All @@ -121,13 +122,13 @@ const splitObservations = (
missingDecisions: Object.fromEntries(
placements
.filter((placement) => !isRecorded(placement))
.map((placement): readonly [string, Decision.Any] => [placement.id, placement.decision]),
.map((placement): readonly [string, SupportedDecision] => [placement.id, placement.decision]),
),
}))

const observationOf = (
decisionId: string,
decision: Decision.Any,
decision: SupportedDecision,
regionPath: ReadonlyArray<string>,
answer: ProviderAnswer,
): Observation =>
Expand All @@ -141,7 +142,7 @@ const observationOf = (

const observedAnswerOf = (answer: DecisionModel.ProviderAnswer): Effect.Effect<ProviderAnswer, AiError.AiError> =>
Effect.mapError(
Effect.fromResult(Schema.decodeResult(ProviderAnswer)(answer)),
Effect.fromResult(Schema.decodeUnknownResult(ProviderAnswer)(answer)),
(issue) =>
AiError.make({
module: 'Discern',
Expand All @@ -152,7 +153,7 @@ const observedAnswerOf = (answer: DecisionModel.ProviderAnswer): Effect.Effect<P

const recordAnswers = (
into: ObservationStore,
decisions: Readonly<Record<string, Decision.Any>>,
decisions: Readonly<Record<string, SupportedDecision>>,
state: Schema.Json,
answers: Readonly<Record<string, DecisionModel.ProviderAnswer>>,
regionPath: ReadonlyArray<string>,
Expand Down Expand Up @@ -183,9 +184,9 @@ const invalidObservationFailure = (error: AiError.AiError): AiError.AiError =>
)

const hitDecisionsOf = (
decisions: Readonly<Record<string, Decision.Any>>,
decisions: Readonly<Record<string, SupportedDecision>>,
hits: Readonly<Record<string, ProviderAnswer>>,
): Readonly<Record<string, Decision.Any>> =>
): Readonly<Record<string, SupportedDecision>> =>
Object.fromEntries(Object.entries(decisions).filter(([id]) => Object.hasOwn(hits, id)))

/**
Expand All @@ -194,7 +195,7 @@ const hitDecisionsOf = (
* only: the stored hits pass on unchanged, because the validator's output drops
* the `_tag` the stored form carries.
*/
const storedHitCheckOf = (request: DecisionModel.ProviderOptions): StoredHitCheck => (hits) => {
const storedHitCheckOf = (request: DecisionModel.ProviderOptions<SupportedDecision>): StoredHitCheck => (hits) => {
const decisions = hitDecisionsOf(request.decisions, hits)
return Object.keys(decisions).length === 0
? Effect.void
Expand All @@ -221,7 +222,7 @@ export type Interceptor = (inner: Provider) => Provider

export interface Provider {
readonly decide: (
options: DecisionModel.ProviderOptions,
options: DecisionModel.ProviderOptions<SupportedDecision>,
) => Effect.Effect<DecisionModel.ProviderResponse, AiError.AiError>
}

Expand Down Expand Up @@ -350,7 +351,7 @@ const CLASSIFY_ANSWER = { _tag: 'Classify' } as const
const RATE_ANSWER = { _tag: 'Rate' } as const
const PROBABILITY_ANSWER = { _tag: 'Probability' } as const

const taggedOf = (decision: Decision.Any, answer: Decision.Answer<Decision.Any>) =>
const taggedOf = (decision: SupportedDecision, answer: Decision.Answer<SupportedDecision>) =>
Match.value(decision).pipe(
Match.tag('Classify', () => ({ ...CLASSIFY_ANSWER, ...answer })),
Match.tag('Rate', () => ({ ...RATE_ANSWER, ...answer })),
Expand All @@ -360,8 +361,8 @@ const taggedOf = (decision: Decision.Any, answer: Decision.Answer<Decision.Any>)

const answerEntryOf = (
id: string,
decision: Decision.Any,
answer: Decision.Answer<Decision.Any> | undefined,
decision: SupportedDecision,
answer: Decision.Answer<SupportedDecision> | undefined,
): Effect.Effect<readonly [string, ProviderAnswer], AiError.AiError> =>
answer === undefined
? Effect.fail(unusableAnswerOf(id, 'the model returned no answer for it'))
Expand All @@ -372,8 +373,8 @@ const answerEntryOf = (
})

const providerAnswersOf = (
decisions: Readonly<Record<string, Decision.Any>>,
answers: Decision.Answers<Record<string, Decision.Any>>,
decisions: Readonly<Record<string, SupportedDecision>>,
answers: Decision.Answers<Record<string, SupportedDecision>>,
): Effect.Effect<Readonly<Record<string, ProviderAnswer>>, AiError.AiError> =>
Effect.map(
Effect.forEach(Object.entries(decisions), ([id, decision]) => answerEntryOf(id, decision, answers[id])),
Expand Down
11 changes: 6 additions & 5 deletions packages/discern/src/decision.blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ import { DecisionIdCollisionError } from './DiscernError.schema.js'
import type { Answers, LeafOptions, NodeCore, Pattern, PatternRefusal } from './pattern.blueprint.js'
import { distinctFirstById, notPattern, semanticLeaf } from './pattern.blueprint.js'
import { Probability } from './Route.schema.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'
import { matched, missed, type PatternResult, uncertain } from './Verdict.schema.js'

/** Any Effect decision kind. */
export type AnyDecision = Decision.Any
export type AnyDecision = SupportedDecision

/** The validated answer a decision of kind `D` produces. */
export type Answer<D extends AnyDecision> = D extends Decision.Classify<infer Label>
Expand Down Expand Up @@ -194,7 +195,7 @@ export type DecisionNode<

type AnyNode = DecisionNode<never, AnyDecision, Schema.Constraint | undefined>

const asAnyDecision = <D extends AnyDecision>(decision: D): Decision.Any => decision
const asAnyDecision = <D extends AnyDecision>(decision: D): SupportedDecision => decision

/**
* The per-node answer check for one decision kind. The check decodes the batch
Expand Down Expand Up @@ -726,8 +727,8 @@ export const openClassifyOn =
// Observation
// -------------------------------------------------------------------------------------------------

const recordDecisions = (nodes: ReadonlyArray<NodeCore>): Record<string, Decision.Any> => {
const decisions: Record<string, Decision.Any> = {}
const recordDecisions = (nodes: ReadonlyArray<NodeCore>): Record<string, SupportedDecision> => {
const decisions: Record<string, SupportedDecision> = {}
for (const node of nodes) decisions[node.id] = node.decision
return decisions
}
Expand All @@ -742,7 +743,7 @@ const collidesWith = (node: NodeCore) => (other: NodeCore): boolean =>
*/
const decisionsOf = (
nodes: ReadonlyArray<NodeCore>,
): Result.Result<Record<string, Decision.Any>, DecisionIdCollisionError> =>
): Result.Result<Record<string, SupportedDecision>, DecisionIdCollisionError> =>
Option.match(Arr.findFirst(nodes, (node) => Option.isSome(Arr.findFirst(nodes, collidesWith(node)))), {
onSome: (node) => Result.fail(new DecisionIdCollisionError({ decisionId: node.id })),
onNone: () => Result.succeed(recordDecisions(distinctFirstById(nodes))),
Expand Down
5 changes: 3 additions & 2 deletions packages/discern/src/pattern.blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import * as Option from 'effect/Option'
import type * as Schema from 'effect/Schema'
import { hash } from './ContentAddress.schema.js'
import type { PatternAst } from './PatternAst.schema.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'
import { andResult, matched, missed, orResult, statusIs, statusOf } from './Verdict.schema.js'
import type { PatternResult, PatternStatus } from './Verdict.schema.js'

/** The validated answers one observation batch returned, keyed by decision id. */
export type Answers = Readonly<Record<string, Decision.Answer<Decision.Any>>>
export type Answers = Readonly<Record<string, Decision.Answer<SupportedDecision>>>

/** Options shared by every pattern constructed from a decision node or a guard. */
export interface LeafOptions {
Expand All @@ -41,7 +42,7 @@ export interface LeafOptions {
export interface NodeCore {
readonly id: string
readonly fingerprint: string
readonly decision: Decision.Any
readonly decision: SupportedDecision
readonly schema: Schema.Constraint | undefined
}

Expand Down
6 changes: 3 additions & 3 deletions packages/discern/src/replay-provider.cell.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Sandwich } from '@systemfsoftware/effect-cell-types'
import * as AiError from 'effect/ai/AiError'
import type * as Decision from 'effect/ai/Decision'
import type * as DecisionModel from 'effect/ai/DecisionModel'
import * as Effect from 'effect/Effect'
import type { ObservationSplit, Provider, StoredHitCheck } from './decision-model.blueprint.js'
import type { ProviderAnswer } from './Observation.schema.js'
import { SelectObservationSource, selectObservationSource } from './select-observation-source.workflow.js'
import type { SupportedDecision } from './SupportedDecision.schema.js'

export interface ReplayRequest {
readonly options: DecisionModel.ProviderOptions
readonly options: DecisionModel.ProviderOptions<SupportedDecision>
readonly inner: Provider
readonly split: ObservationSplit
readonly onMissing: 'fail' | 'ask'
Expand All @@ -17,7 +17,7 @@ export interface ReplayRequest {

export type ReplayRead = (typeof SelectObservationSource)['Encoded'] & {
readonly hits: Readonly<Record<string, ProviderAnswer>>
readonly missingDecisions: Readonly<Record<string, Decision.Any>>
readonly missingDecisions: Readonly<Record<string, SupportedDecision>>
readonly state: DecisionModel.ProviderOptions['state']
readonly inner: Provider
readonly validate: StoredHitCheck
Expand Down
Loading
Loading