Module: MCPClient::SchemaValidator::Evaluation
- Included in:
- MCPClient::SchemaValidator
- Defined in:
- lib/mcp_client/schema_validator/evaluation.rb
Overview
Applying schemas to one instance value, without recursing per schema. Extended into SchemaValidator, so the methods are its own.
A validation walks two things at once: down the instance, and — at each
value — through the schemas that apply to it. Only the first nests the
data, and only the first is counted by MAX_NODE_DEPTH; the second is a
$ref hop or a composition branch applied to the same value, bounded
by MAX_REF_DEPTH and the node-visit cap instead.
A schema composed through a handful of $defs mixins applies several
of those per instance level, so if each of them cost a Ruby frame the
stack would grow with the product of the instance depth and the mixin
count — and a conforming result would abort on a transport reader
thread's stack, which is where a tool call is validated. So the
same-instance applications run on an explicit stack here (this
module's pending list) rather than on the interpreter's: a step is
requested by returning [:apply, schema, ref_depth, speculative, continuation, collect] and finished by returning [:done, errors, evaluated], and validate_node drives the two until
the outermost application is done. A Ruby frame is then spent only on
a step into a child value (validate_child), which is
exactly what MAX_NODE_DEPTH bounds.
Every application of a schema to an object or an array keeps what it
evaluated of the value (an Evaluated): the annotations
unevaluatedProperties and unevaluatedItems read, collected from the
node's own keywords and from every in-place applicator whose subschema
passed. A subschema hands its own record back with its verdict, and a
failed one hands back nothing.
Defined Under Namespace
Classes: Application
Constant Summary collapse
- COMPOSITION_STEPS =
The composition keywords, in the order they are applied. Each step takes the application and a continuation, and returns a step.
%i[compose_all_of compose_any_of compose_one_of compose_not compose_conditional compose_dependent_schemas].freeze
- REFERENCE_KEYWORDS =
The keywords that apply another schema to the same instance before the node's own keywords:
$refand the dynamic references (2020-12 Core Section 8.2.3.2, 2019-09 Core Section 8.2.4.2.1). Applied in this order, each alongside the others (draft-07's$refalone replaces its siblings). %w[$ref $dynamicRef $recursiveRef].freeze
Instance Method Summary collapse
-
#all_of_branch(app, subs, idx, cont) ⇒ Array
A step.
-
#applied_references(app) ⇒ Array<String>
The reference keywords a node applies:
$refand the dynamic references the dialect defines. -
#apply_keywords(app) ⇒ Array
Apply the assertions this validator evaluates for the value's own type, then hand over to the composition keywords.
-
#apply_references(app, keywords) ⇒ Array
Apply the references a schema object carries, one after the other, then its own keywords.
-
#apply_unevaluated(app) ⇒ void
unevaluatedProperties/unevaluatedItems(JSON Schema 2020-12 Core Sections 11.3 and 11.2): the keyword's schema applies to every member or item nothing applied to this value evaluated, and what it validated counts as evaluated from then on. -
#branch_verdict(app, sub, &cont) ⇒ Array
The verdict of a branch evaluated speculatively (its errors are a verdict, not output): :fail when a supported assertion rejected the value, :pass when every assertion was evaluated and accepted it, :undecided when it was accepted only as far as the validator could evaluate.
-
#branch_verdicts(app, subs, stop:, decided:, &cont) ⇒ Array
The verdicts of a keyword's branches, and what each branch evaluated.
-
#compose(app, index = 0) ⇒ Array
Run the composition keywords in order, each handed the step that continues with the next.
-
#compose_all_of(app, &cont) ⇒ Array
allOf: the first failing branch decides it, and later branches are not evaluated (they cannot change the outcome, but could abort it).
-
#compose_any_of(app, &cont) ⇒ Array
anyOf is monotonic: a definite pass decides it whatever the other branches, and only undecided branches leave it undecided.
-
#compose_conditional(app, &cont) ⇒ Array
if / then / else.
-
#compose_dependent_schemas(app, &cont) ⇒ Array
The schema half of a dependency:
dependentSchemasin 2019-09 and 2020-12, and the schema entries of draft-07'sdependencies(JSON Schema 2020-12 Core Section 10.2.2.4, draft-07 Section 6.5.7). -
#compose_not(app, &cont) ⇒ Array
A step.
-
#compose_one_of(app, &cont) ⇒ Array
oneOf: two definite passes decide it; with an undecided branch and at most one pass, "exactly one" cannot be told either way.
-
#dependency_branch(app, subs, idx, cont) ⇒ Array
A step.
-
#finish_node(app) ⇒ Array
Finish an application.
-
#merge_passed(app, verdicts, evaluations) ⇒ void
Take over what every passed branch evaluated of the value.
-
#reads_annotations?(schema, dialect) ⇒ Boolean
Whether the node carries an unevaluated keyword that reads the annotations of the schemas applied to its value.
- #ref_problem(app, message) ⇒ Array(Symbol, Array<String>)
-
#ref_target(app, keyword = '$ref') ⇒ Array(Symbol, Object)
Resolve a reference a schema object carries.
-
#resume_conditional(app, undecided, cont) ⇒ Array
Resume after measuring the branches: what they could not evaluate is not this node's uncertainty (the condition's already is).
-
#start_node(data, schema, path, ctx, ref_depth, collecting: false) ⇒ Array
Begin applying one (sub)schema to the value, under the bound on the walk itself: one more node visited.
-
#triggered_dependencies(app) ⇒ Array<Array(String, Object)>
The trigger and schema of every dependency the instance turns on, in document order.
-
#unconditional_conditional(app, cont) ⇒ Array
A condition this validator cannot decide still settles the instance when both outcomes reject it: exactly one of
thenandelseis applied (JSON Schema 2020-12 Section 10.2.2.2 and 10.2.2.3), so a value both of them reject is invalid whichever way the condition goes. -
#unusable_ref?(ref, ctx, from) ⇒ Boolean
Whether it is a reference this validator never follows.
Instance Method Details
#all_of_branch(app, subs, idx, cont) ⇒ Array
Returns a step.
320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 320 def all_of_branch(app, subs, idx, cont) return cont.call if idx >= subs.length [:apply, subs[idx], app.ref_depth, false, lambda do |errors, evaluated| if errors.empty? app.evaluated&.merge!(evaluated) next all_of_branch(app, subs, idx + 1, cont) end app.errors << "#{app.path}: does not satisfy allOf/#{idx} (#{clip(errors.first.to_s)})" cont.call end, app.collecting] end |
#applied_references(app) ⇒ Array<String>
The reference keywords a node applies: $ref and the dynamic
references the dialect defines. Where a dynamic reference binds is
References#dynamic_binding's business, and #ref_target asks it
once the plain target has resolved.
105 106 107 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 105 def applied_references(app) REFERENCE_KEYWORDS.select { |keyword| app.schema.key?(keyword) && keyword_known?(keyword, app.dialect) } end |
#apply_keywords(app) ⇒ Array
Apply the assertions this validator evaluates for the value's own type, then hand over to the composition keywords. A step into a child value happens here, and is the one place a Ruby frame is spent.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 186 def apply_keywords(app) data = app.data schema = app.schema errors = app.errors errors.concat(validate_type(data, schema['type'], app.path)) if schema.key?('type') # A node its own `type` already rejected is decided: the keywords for # the value's actual type could only add detail, and evaluating them # would spend the peer's `patternProperties` expressions and item # schemas on a value this schema has refused. return compose(app) unless errors.empty? errors.concat(validate_enum(data, schema, app.path)) case data when Hash then errors.concat(validate_object(data, schema, app.path, app.ctx, app.dialect, app.evaluated)) when Array then errors.concat(validate_array(data, schema, app.path, app.ctx, app.dialect, app.evaluated)) when String then errors.concat(validate_string(data, schema, app.path, app.ctx.deadline)) when Numeric then errors.concat(validate_number(data, schema, app.path, app.dialect)) end compose(app) end |
#apply_references(app, keywords) ⇒ Array
Apply the references a schema object carries, one after the other, then its own keywords. draft-07: "$ref" replaces the schema it appears in; later drafts apply it alongside the sibling keywords.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 115 def apply_references(app, keywords) return apply_keywords(app) if keywords.empty? keyword = keywords.first replaces = keyword == '$ref' && app.dialect == DRAFT_07 kind, target = ref_target(app, keyword) if kind == :errors return [:done, target, app.evaluated] if replaces app.errors.concat(target) return apply_references(app, keywords.drop(1)) end [:apply, target, app.ref_depth + 1, false, lambda do |errors, evaluated| next [:done, errors, evaluated] if replaces app.errors.concat(errors) app.evaluated&.merge!(evaluated) if errors.empty? apply_references(app, keywords.drop(1)) end, app.collecting] end |
#apply_unevaluated(app) ⇒ void
This method returns an undefined value.
unevaluatedProperties / unevaluatedItems (JSON Schema 2020-12
Core Sections 11.3 and 11.2): the keyword's schema applies to every
member or item nothing applied to this value evaluated, and what it
validated counts as evaluated from then on.
241 242 243 244 245 246 247 248 249 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 241 def apply_unevaluated(app) keyword = app.data.is_a?(Hash) ? 'unevaluatedProperties' : 'unevaluatedItems' sub = app.schema[keyword] return unless keyword_known?(keyword, app.dialect) && schema_value?(sub) errors = app.data.is_a?(Hash) ? unevaluated_property_errors(app, sub) : unevaluated_item_errors(app, sub) app.errors.concat(errors) app.evaluated.all! end |
#branch_verdict(app, sub, &cont) ⇒ Array
The verdict of a branch evaluated speculatively (its errors are a verdict, not output): :fail when a supported assertion rejected the value, :pass when every assertion was evaluated and accepted it, :undecided when it was accepted only as far as the validator could evaluate. Non-monotonic compositions (not, oneOf, if) never treat :undecided as a match. A definite verdict leaves no uncertainty behind: what a failing branch could not evaluate does not matter once it failed. The continuation also receives what the branch evaluated of the value (nothing, for a failed one).
503 504 505 506 507 508 509 510 511 512 513 514 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 503 def branch_verdict(app, sub, &cont) ctx = app.ctx before = ctx.undecided [:apply, sub, app.ref_depth, true, lambda do |errors, evaluated| unless errors.empty? ctx.undecided = before next cont.call(:fail, nil) end cont.call(ctx.undecided == before ? :pass : :undecided, evaluated) end, app.collecting] end |
#branch_verdicts(app, subs, stop:, decided:, &cont) ⇒ Array
The verdicts of a keyword's branches, and what each branch evaluated.
Evaluation stops as soon as the branches seen so far settle the
composition (stop): a branch that cannot change the outcome is not
evaluated, so it cannot abort a decided validation. Once the
composition is decided — early, or after every branch (decided) —
the uncertainty count is restored to what it was before the branches
ran.
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 471 def branch_verdicts(app, subs, stop:, decided:, &cont) before = app.ctx.undecided verdicts = [] evaluations = [] advance = nil advance = lambda do if verdicts.length >= subs.length || stop.call(verdicts) app.ctx.undecided = before if stop.call(verdicts) || decided.call(verdicts) next cont.call(verdicts, evaluations) end branch_verdict(app, subs[verdicts.length]) do |verdict, evaluated| verdicts << verdict evaluations << evaluated advance.call end end advance.call end |
#compose(app, index = 0) ⇒ Array
Run the composition keywords in order, each handed the step that continues with the next.
212 213 214 215 216 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 212 def compose(app, index = 0) return finish_node(app) if index >= COMPOSITION_STEPS.length send(COMPOSITION_STEPS[index], app) { compose(app, index + 1) } end |
#compose_all_of(app, &cont) ⇒ Array
allOf: the first failing branch decides it, and later branches are not evaluated (they cannot change the outcome, but could abort it).
308 309 310 311 312 313 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 308 def compose_all_of(app, &cont) subs = app.schema['allOf'] return cont.call unless subs.is_a?(Array) all_of_branch(app, subs, 0, cont) end |
#compose_any_of(app, &cont) ⇒ Array
anyOf is monotonic: a definite pass decides it whatever the other branches, and only undecided branches leave it undecided. Where an unevaluated keyword reads the annotations, every branch is evaluated (each one that passes contributes what it evaluated); otherwise the first definite pass ends it, so a branch that cannot change the outcome cannot abort a decided validation.
342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 342 def compose_any_of(app, &cont) subs = app.schema['anyOf'] return cont.call unless subs.is_a?(Array) branch_verdicts(app, subs, stop: ->(vs) { !app.collecting && vs.include?(:pass) }, decided: ->(vs) { vs.all?(:fail) }) do |verdicts, evaluations| if verdicts.all?(:fail) app.errors << "#{app.path}: does not satisfy any schema in anyOf" else merge_passed(app, verdicts, evaluations) end cont.call end end |
#compose_conditional(app, &cont) ⇒ Array
if / then / else. An if asserts nothing by itself, but it is
evaluated even without then or else: the annotations of a
condition that passed are what an unevaluated* beside it reads
(JSON Schema 2020-12 Section 10.2.2.1). An undecided condition
applies neither branch, but may still be settled by the branches
agreeing (#unconditional_conditional). A condition that passed
evaluated the value, and so does the branch applied.
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 405 def compose_conditional(app, &cont) schema = app.schema return cont.call unless schema.key?('if') branch_verdict(app, schema['if']) do |verdict, evaluated| branch = { pass: 'then', fail: 'else' }[verdict] next unconditional_conditional(app, cont) if branch.nil? app.evaluated&.merge!(evaluated) if verdict == :pass next cont.call unless schema.key?(branch) [:apply, schema[branch], app.ref_depth, false, lambda do |errors, branch_evaluated| app.errors.concat(errors) app.evaluated&.merge!(branch_evaluated) if errors.empty? cont.call end, app.collecting] end end |
#compose_dependent_schemas(app, &cont) ⇒ Array
The schema half of a dependency: dependentSchemas in 2019-09 and
2020-12, and the schema entries of draft-07's dependencies (JSON
Schema 2020-12 Core Section 10.2.2.4, draft-07 Section 6.5.7). Each
dependency whose trigger the instance carries applies its schema to
that same instance, so — like allOf — it runs on the trampoline
rather than on the interpreter's stack.
259 260 261 262 263 264 265 266 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 259 def compose_dependent_schemas(app, &cont) return cont.call unless app.data.is_a?(Hash) subs = triggered_dependencies(app) return cont.call if subs.empty? dependency_branch(app, subs, 0, cont) end |
#compose_not(app, &cont) ⇒ Array
Returns a step.
387 388 389 390 391 392 393 394 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 387 def compose_not(app, &cont) return cont.call unless app.schema.key?('not') branch_verdict(app, app.schema['not']) do |verdict, _evaluated| app.errors << "#{app.path}: value satisfies the schema in not" if verdict == :pass cont.call end end |
#compose_one_of(app, &cont) ⇒ Array
oneOf: two definite passes decide it; with an undecided branch and at most one pass, "exactly one" cannot be told either way.
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 361 def compose_one_of(app, &cont) subs = app.schema['oneOf'] return cont.call unless subs.is_a?(Array) branch_verdicts(app, subs, stop: ->(vs) { vs.count(:pass) > 1 }, decided: ->(vs) { vs.none?(:undecided) }) do |verdicts, evaluations| matches = verdicts.count(:pass) if matches > 1 || (verdicts.none?(:undecided) && matches != 1) app.errors << "#{app.path}: satisfies #{matches} schemas in oneOf, expected exactly one" elsif matches == 1 merge_passed(app, verdicts, evaluations) end cont.call end end |
#dependency_branch(app, subs, idx, cont) ⇒ Array
Returns a step.
289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 289 def dependency_branch(app, subs, idx, cont) return cont.call if idx >= subs.length trigger, sub = subs[idx] [:apply, sub, app.ref_depth, false, lambda do |errors, evaluated| if errors.empty? app.evaluated&.merge!(evaluated) else app.errors << "#{app.path}: does not satisfy the schema required by property " \ "'#{clip(trigger)}' (#{clip(errors.first.to_s)})" end dependency_branch(app, subs, idx + 1, cont) end, app.collecting] end |
#finish_node(app) ⇒ Array
Finish an application. The unevaluated keywords come last: they read what every other keyword and every passed applicator evaluated of the value. A keyword the validator does not evaluate makes this node's verdict partial, so a pass here is not a proof for not / oneOf / if. A node its supported assertions already rejected is decided whatever else it holds, and pays for no such measurement. Errors raised by nested nodes were counted when they were produced; only this node's own errors are new.
228 229 230 231 232 233 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 228 def finish_node(app) ctx = app.ctx apply_unevaluated(app) if app.errors.empty? && app.evaluated ctx.undecided += 1 if app.errors.empty? && partial_keywords?(app.schema, app.dialect, app.data, ctx) [:done, count_errors(ctx, app.errors, already_counted: ctx.errors - app.counted_before), app.evaluated] end |
#merge_passed(app, verdicts, evaluations) ⇒ void
This method returns an undefined value.
Take over what every passed branch evaluated of the value.
379 380 381 382 383 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 379 def merge_passed(app, verdicts, evaluations) return unless app.evaluated verdicts.each_with_index { |verdict, idx| app.evaluated.merge!(evaluations[idx]) if verdict == :pass } end |
#reads_annotations?(schema, dialect) ⇒ Boolean
Returns whether the node carries an unevaluated keyword that reads the annotations of the schemas applied to its value.
93 94 95 96 97 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 93 def reads_annotations?(schema, dialect) %w[unevaluatedProperties unevaluatedItems].any? do |keyword| keyword_known?(keyword, dialect) && schema_value?(schema[keyword]) end end |
#ref_problem(app, message) ⇒ Array(Symbol, Array<String>)
177 178 179 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 177 def ref_problem(app, ) [:errors, count_errors(app.ctx, ["#{app.path}: #{}"])] end |
#ref_target(app, keyword = '$ref') ⇒ Array(Symbol, Object)
Resolve a reference a schema object carries.
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 142 def ref_target(app, keyword = '$ref') ctx = app.ctx ref = app.schema[keyword] if unusable_ref?(ref, ctx, app.schema) return ref_problem(app, "external #{keyword} #{clip(ref.inspect)} is not dereferenced") end if app.ref_depth >= MAX_REF_DEPTH raise Aborted, "#{keyword} chain exceeds #{MAX_REF_DEPTH} hops (cycle?) at #{clip(ref.inspect)}" end target = resolve_reference(ctx.root, ref, ctx.dialect, ctx, from: app.schema) return ref_problem(app, "unresolvable local #{keyword} #{clip(ref.inspect)}") if target.equal?(UNRESOLVED) if keyword != '$ref' kind, bound = dynamic_binding(app.schema, keyword, ctx.root, ctx.dialect, ctx) target = bound if kind == :bound end unless schema_value?(target) return ref_problem(app, "#{keyword} #{clip(ref.inspect)} does not point at a schema") end [:target, target] end |
#resume_conditional(app, undecided, cont) ⇒ Array
Resume after measuring the branches: what they could not evaluate is not this node's uncertainty (the condition's already is).
456 457 458 459 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 456 def resume_conditional(app, undecided, cont) app.ctx.undecided = undecided cont.call end |
#start_node(data, schema, path, ctx, ref_depth, collecting: false) ⇒ Array
Begin applying one (sub)schema to the value, under the bound on the walk itself: one more node visited.
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 75 def start_node(data, schema, path, ctx, ref_depth, collecting: false) count_visit(ctx) return [:done, [], nil] if schema == true return [:done, count_errors(ctx, ["#{path}: schema false accepts no value"]), nil] if schema == false return [:done, [], nil] unless schema.is_a?(Hash) dialect = node_dialect(schema, ctx) app = Application.new(data: data, path: path, ctx: ctx, schema: schema, dialect: dialect, ref_depth: ref_depth, errors: [], counted_before: ctx.errors, evaluated: (Evaluated.new if data.is_a?(Hash) || data.is_a?(Array)), collecting: collecting || reads_annotations?(schema, dialect)) apply_references(app, applied_references(app)) end |
#triggered_dependencies(app) ⇒ Array<Array(String, Object)>
Returns the trigger and schema of every dependency the instance turns on, in document order.
271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 271 def triggered_dependencies(app) subs = [] %w[dependentSchemas dependencies].each do |keyword| map = app.schema[keyword] if keyword_known?(keyword, app.dialect) next unless map.is_a?(Hash) map.each do |trigger, sub| subs << [trigger.to_s, sub] if schema_value?(sub) && property_present?(app.data, trigger) end end subs end |
#unconditional_conditional(app, cont) ⇒ Array
A condition this validator cannot decide still settles the instance
when both outcomes reject it: exactly one of then and else is
applied (JSON Schema 2020-12 Section 10.2.2.2 and 10.2.2.3), so a
value both of them reject is invalid whichever way the condition
goes. Reporting nothing there would turn a definite failure into a
pass — and, under :strict, accept a result the schema rejects. Only
a definite rejection counts on each side; what a branch could not
evaluate leaves the conditional undecided as before.
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 435 def unconditional_conditional(app, cont) schema = app.schema return cont.call unless schema.key?('then') && schema.key?('else') undecided = app.ctx.undecided [:apply, schema['then'], app.ref_depth, true, lambda do |then_errors, _evaluated| next resume_conditional(app, undecided, cont) if then_errors.empty? [:apply, schema['else'], app.ref_depth, true, lambda do |else_errors, _else_evaluated| unless else_errors.empty? app.errors << "#{app.path}: fails both then and else of an if this validator cannot decide " \ "(#{clip(then_errors.first.to_s)})" end resume_conditional(app, undecided, cont) end, app.collecting] end, app.collecting] end |
#unusable_ref?(ref, ctx, from) ⇒ Boolean
Returns whether it is a reference this validator never follows.
170 171 172 |
# File 'lib/mcp_client/schema_validator/evaluation.rb', line 170 def unusable_ref?(ref, ctx, from) !ref.is_a?(String) || external_ref?(ref, ctx.root, ctx.dialect, ctx, from: from) end |