diff --git a/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs b/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs index 3b19baa..02477c1 100644 --- a/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs +++ b/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs @@ -114,7 +114,7 @@ public void Reset() // ── Frame stack ─────────────────────────────────────────────────────────── - private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit } + private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit, Free } private struct Frame { @@ -125,6 +125,7 @@ private struct Frame public ulong Emitted; // Object: keys emitted public ulong Cand; // Object key-match candidates / StrEnum / Lit candidates public int MatchLen; // chars into current key / literal + public int FreeDepth; // Free: nesting balance of {}/[] in a free value public bool SeenDigit; // Num public bool SeenDot; // Num public bool SeenSign; // Num @@ -160,6 +161,14 @@ private struct Frame // Lit sub-state: single matching state. private const int LMatch = 0; + // Free-value sub-states (issue #378): a permissive value of unknown type, balanced to completion. + // Strings are token-level (the <|"|> quote, via HandleQuote); structure is byte-level. + private const int FrStart = 0; // value not yet started + private const int FrBare = 1; // a bare scalar — ends at a top-level delimiter + private const int FrBalanced = 2; // inside {…}/[…], FreeDepth ≥ 1, not in a string + private const int FrStr = 3; // a top-level <|"|>…<|"|> string value (token-level content) + private const int FrBalancedStr = 4; // a <|"|>…<|"|> string inside a balanced free value + private void PushObject(CompiledObject obj) { ref var f = ref _stack[_depth++]; @@ -198,6 +207,9 @@ private bool PushValue(CompiledNode node) case JsonSchemaKind.Array: f.Kind = FK.Array; f.State = AExpectOpen; break; + case JsonSchemaKind.Any: // free value (issue #378) + f.Kind = FK.Free; f.State = FrStart; + break; default: // Number / Integer / Boolean / Null if (node.Literals is not null) { f.Kind = FK.Lit; f.State = LMatch; f.Cand = AllBits(node.Literals.Length); } else { f.Kind = FK.Num; f.State = NStart; } @@ -331,8 +343,10 @@ private int ComputeMask(float[] buf) int kept = 0; // Token-level tops: free string content and the open/close quote. Handle without a - // per-token byte simulation for speed. - if (top.Kind == FK.Str && top.State == SContent) + // per-token byte simulation for speed. Free-value string content (issue #378) behaves + // identically — any non-EOG token stays, the quote token closes. + if ((top.Kind == FK.Str && top.State == SContent) + || (top.Kind == FK.Free && top.State is FrStr or FrBalancedStr)) { // Any non-forbidden token stays in content; the quote token closes. Forbid only EOG — // a tiny set, so mask those ids directly rather than testing all 262k tokens against it. @@ -360,6 +374,12 @@ private int ComputeMask(float[] buf) Array.Clear(_firstByteOk); bool quoteAllowed = CollectFirstBytes(_depth - 1, _firstByteOk) || IsQuoteAccepted(); + // A free value's non-string states (issue #378) mark all first-bytes, so without a shortcut + // every step would simulate the whole vocabulary. A non-quote token carrying none of the + // bytes that can balance / delimit a free value is pure content that keeps it alive — admit it + // without the per-token replay (the analogue of the token-level free-content path above). + bool fastFree = top.Kind == FK.Free && top.State is FrStart or FrBare or FrBalanced; + for (int id = 0; id < buf.Length; id++) { var bytes = _vocab.TokenBytes(id); @@ -368,14 +388,33 @@ private int ComputeMask(float[] buf) else if (bytes.Length == 0) candidate = false; else candidate = _firstByteOk[bytes[0]]; - if (candidate && SimulateToken(id)) - kept++; - else - buf[id] = float.NegativeInfinity; + bool ok = candidate + && ((fastFree && id != _quoteId && !ContainsFreeStructural(bytes)) || SimulateToken(id)); + if (ok) kept++; + else buf[id] = float.NegativeInfinity; } + + // Belt-and-suspenders: forbid every EOG id regardless of its bytes. The fastFree shortcut + // admits content tokens without simulation, so a tokenizer whose EOS decodes to ordinary + // (non-structural) text could otherwise pass first-byte pruning inside a free value and + // truncate the call mid-object. (Mirrors the JSON constraint's sweep.) + foreach (int id in _forbidden) + if ((uint)id < (uint)buf.Length && !float.IsNegativeInfinity(buf[id])) + { buf[id] = float.NegativeInfinity; kept--; } + return kept; } + /// Whether a token carries any byte that can balance or delimit a free value (and so + /// must be simulated rather than fast-pathed as pure content). Gemma strings are the <|"|> + /// token, so the quote is not a structural byte here. + private static bool ContainsFreeStructural(ReadOnlySpan bytes) + { + for (int i = 0; i < bytes.Length; i++) + if (bytes[i] is (byte)'{' or (byte)'}' or (byte)'[' or (byte)']' or (byte)',') return true; + return false; + } + private bool SimulateToken(int token) { // Save/restore the active frames around a trial replay. _depth is tiny (1–3 in practice), and @@ -409,6 +448,7 @@ private bool RunToken(int tokenId) case FK.StrEnum when top.State == SeExpectOpen: return false; case FK.Str when top.State == SContent: // free content: any non-EOG token stays + case FK.Free when top.State is FrStr or FrBalancedStr: // free-value string content return !_forbidden.Contains(tokenId); case FK.StrEnum when top.State == SeMatch: break; // enum content → byte-walk below @@ -436,15 +476,29 @@ private bool HandleQuote() case FK.Array when top.State is AExpectItemOrClose or AExpectItem: { var item = top.Node!.Items!; - if (item.Kind != JsonSchemaKind.String) return false; // only a string item opens on a quote + // A string item — or a FREE item (issue #378) — opens on the quote token (other item + // kinds open on a structural byte instead). + if (item.Kind is not (JsonSchemaKind.String or JsonSchemaKind.Any)) return false; top.State = AExpectCommaOrClose; // resume here after the item if (_depth >= MaxDepth) return false; ref var f = ref _stack[_depth++]; f = default; f.Node = item; - if (item.Literals is not null) { f.Kind = FK.StrEnum; f.State = SeMatch; f.Cand = AllBits(item.Literals.Length); } + if (item.Kind == JsonSchemaKind.Any) { f.Kind = FK.Free; f.State = FrStr; } // free string item + else if (item.Literals is not null) { f.Kind = FK.StrEnum; f.State = SeMatch; f.Cand = AllBits(item.Literals.Length); } else { f.Kind = FK.Str; f.State = SContent; } return true; } + case FK.Free: + // A free value's strings are <|"|>…<|"|>: open one at the value start or inside a + // balanced object/array; close the one currently open. + switch (top.State) + { + case FrStart: top.State = FrStr; return true; // open top-level string value + case FrStr: _depth--; return PostValue(); // close → value done + case FrBalanced: top.State = FrBalancedStr; return true; // open string inside {}/[] + case FrBalancedStr: top.State = FrBalanced; return true; // close inner string + default: return false; // FrBare: a quote isn't legal + } default: return false; // quote not legal here } @@ -461,7 +515,10 @@ private bool IsQuoteAccepted() FK.StrEnum => top.State == SeExpectOpen || (top.State == SeMatch && AnyComplete(top.Node!.Literals!, top.Cand, top.MatchLen)), FK.Array => top.State is AExpectItemOrClose or AExpectItem - && top.Node!.Items!.Kind == JsonSchemaKind.String, + && top.Node!.Items!.Kind is JsonSchemaKind.String or JsonSchemaKind.Any, + // A free value can open a string at its start or inside a balanced object/array; the close + // of an open free string is handled by the free-content mask path, not here. + FK.Free => top.State is FrStart or FrBalanced, _ => false, }; } @@ -492,7 +549,8 @@ private bool FeedRawBytes(ReadOnlySpan bytes) } private static bool IsTokenLevel(in Frame f) => - (f.Kind == FK.Str) || (f.Kind == FK.StrEnum && f.State == SeExpectOpen); + (f.Kind == FK.Str) || (f.Kind == FK.StrEnum && f.State == SeExpectOpen) + || (f.Kind == FK.Free && f.State is FrStr or FrBalancedStr); private enum Step { Consume, Retry, Reject } @@ -505,10 +563,43 @@ private Step StepByte(ref Frame top, byte b) case FK.Num: return StepNum(ref top, b); case FK.Lit: return StepLit(ref top, b); case FK.StrEnum: return StepStrEnum(ref top, b); // SeMatch only reaches here + case FK.Free: return StepFree(ref top, b); default: return Step.Reject; } } + /// Free-value (issue #378) byte handling: balance a bare scalar / object / array to + /// completion, then pop so the enclosing object resumes enforcing its declared/required keys. + /// Strings (<|"|>…<|"|>) are opened/closed by the quote token in HandleQuote, + /// never byte-walked here. + private Step StepFree(ref Frame f, byte b) + { + switch (f.State) + { + case FrStart: + if (IsWs(b)) return Step.Consume; + if (b is (byte)'{' or (byte)'[') { f.FreeDepth = 1; f.State = FrBalanced; return Step.Consume; } + if (b is (byte)',' or (byte)'}' or (byte)']') return Step.Reject; // a value can't be empty + f.State = FrBare; return Step.Consume; // bare scalar start + + case FrBare: + if (b is (byte)',' or (byte)'}' or (byte)']') { _depth--; return PostValueRetry(); } + return Step.Consume; + + case FrBalanced: + if (b is (byte)'{' or (byte)'[') { f.FreeDepth++; return Step.Consume; } + if (b is (byte)'}' or (byte)']') + { + if (--f.FreeDepth == 0) { _depth--; return PostValueOrDone(); } + return Step.Consume; + } + return Step.Consume; // bare keys / ':' / ',' / scalars are content + + default: + return Step.Reject; // FrStr / FrBalancedStr are token-level + } + } + private static bool IsWs(byte b) => b is (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r'; // After a value frame pops, resume the parent (object/array) at its post-value state. @@ -708,6 +799,10 @@ private bool CollectFirstBytes(int d, bool[] set) CollectStrEnum(ref f, set, ref quote); popThrough = false; break; + case FK.Free: + CollectFree(ref f, set); // quote handled by IsQuoteAccepted + popThrough = false; + break; } if (!popThrough) break; d--; // bare value can end here — parent's bytes also start a token @@ -715,6 +810,15 @@ private bool CollectFirstBytes(int d, bool[] set) return quote; } + private static void CollectFree(ref Frame f, bool[] set) + { + // A free value admits almost anything — mark broadly and let the (fast-pathed) simulate pass + // decide. The only restriction is that the value can't START with a parent delimiter (empty + // value). FrStr/FrBalancedStr never reach here (token-level free content is masked separately). + for (int i = 0; i < 256; i++) set[i] = true; + if (f.State == FrStart) { set[','] = false; set['}'] = false; set[']'] = false; } + } + private static bool CollectObject(ref Frame f, bool[] set) { var obj = f.Obj!; @@ -816,6 +920,15 @@ private static void MarkValueFirstBytes(CompiledNode node, bool[] set) case JsonSchemaKind.Array: set['['] = true; break; case JsonSchemaKind.Object: set['{'] = true; break; case JsonSchemaKind.String: break; // opens with the quote token, not a byte + case JsonSchemaKind.Any: + // A free array item (issue #378) can be any value — mark every value-START byte. A + // string item opens on the <|"|> quote token (admitted by IsQuoteAccepted, not a byte), + // so the quote is not marked here. (Don't touch ']'; the array frame marks it for the + // empty-array close, and unsetting it here would wrongly forbid closing.) + set['{'] = true; set['['] = true; set['-'] = true; + MarkDigits(set); + set['t'] = true; set['f'] = true; set['n'] = true; // true / false / null + break; default: if (node.Literals is { } lits) foreach (var l in lits) { if (l.Length > 0) set[l[0]] = true; } diff --git a/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs b/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs index f9115f6..f14014f 100644 --- a/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs +++ b/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs @@ -153,7 +153,7 @@ private void ResetPreamble() // ── Frame stack ─────────────────────────────────────────────────────────── - private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit } + private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit, Free } private struct Frame { @@ -165,10 +165,11 @@ private struct Frame public ulong Cand; // Object key-match / StrEnum / Lit candidates public int MatchLen; // chars into current key / literal public int PendingKey; // Object: key index whose value to push at ':' + public int FreeDepth; // Free: nesting balance of {}/[] in a free value public bool SeenDigit; // Num public bool SeenDot; // Num public bool SeenSign; // Num - public bool Escaped; // Str content: previous byte was '\' + public bool Escaped; // Str / Free content: previous byte was '\' } // Object sub-states. @@ -202,6 +203,13 @@ private struct Frame // Lit sub-state. private const int LMatch = 0; + // Free-value sub-states (issue #378): a permissive value of unknown type, balanced to completion. + private const int FrStart = 0; // value not yet started + private const int FrStr = 1; // a top-level string value + private const int FrBare = 2; // a bare scalar — ends at a top-level delimiter + private const int FrBalanced = 3; // inside {…}/[…], FreeDepth ≥ 1 + private const int FrBalancedStr = 4; // a string inside a balanced free value + // Preamble (watching) sub-states. private const int WStart = 0; // before envelope '{' (NameValueObject) / scanning name (NameThenSeparator) private const int WKeyExpect = 1; // '"' opens a key, '}' gives up @@ -240,6 +248,9 @@ private bool PushValue(CompiledNode node) case JsonSchemaKind.Array: f.Kind = FK.Array; f.State = AExpectOpen; break; + case JsonSchemaKind.Any: // free value (issue #378) + f.Kind = FK.Free; f.State = FrStart; + break; default: // Number / Integer / Boolean / Null if (node.Literals is not null) { f.Kind = FK.Lit; f.State = LMatch; f.Cand = AllBits(node.Literals.Length); } else { f.Kind = FK.Num; f.State = NStart; } @@ -356,10 +367,14 @@ private int ComputeMask(float[] buf) // or escape need the full replay. Disabled mid-escape (the next byte is consumed literally), so // those tokens fall back to SimulateToken. This is the byte-level analogue of the Gemma sibling's // token-level free-content shortcut. - bool fastStrContent; + bool fastStrContent, fastFree; { ref var top = ref _stack[_depth - 1]; fastStrContent = top.Kind == FK.Str && top.State == SContent && !top.Escaped; + // A free value (issue #378) marks all 256 first-bytes too; a token carrying none of the + // structural bytes that can balance / delimit / quote it is pure content valid in any free + // state, so it skips SimulateToken — same shape as the string-content fast-path. + fastFree = top.Kind == FK.Free && !top.Escaped; } int kept = 0; @@ -369,7 +384,9 @@ private int ComputeMask(float[] buf) // Empty-byte tokens (EOG / control) never advance the structure — forbidding them keeps // an end-of-generation token from truncating the call mid-object. if (bytes.Length == 0 || !_firstByteOk[bytes[0]]) { buf[id] = float.NegativeInfinity; continue; } - bool ok = (fastStrContent && !ContainsQuoteOrBackslash(bytes)) || SimulateToken(id); + bool ok = (fastStrContent && !ContainsQuoteOrBackslash(bytes)) + || (fastFree && !ContainsFreeStructural(bytes)) + || SimulateToken(id); if (ok) kept++; else buf[id] = float.NegativeInfinity; } @@ -390,6 +407,16 @@ private static bool ContainsQuoteOrBackslash(ReadOnlySpan bytes) return false; } + /// Whether a token carries any byte that could balance, delimit, quote, or escape a free + /// value (and so must be fully simulated rather than fast-pathed as pure content). + private static bool ContainsFreeStructural(ReadOnlySpan bytes) + { + for (int i = 0; i < bytes.Length; i++) + if (bytes[i] is (byte)'{' or (byte)'}' or (byte)'[' or (byte)']' + or (byte)'"' or (byte)'\\' or (byte)',') return true; + return false; + } + private bool SimulateToken(int token) { int savedDepth = _depth; @@ -435,10 +462,56 @@ private Step StepByte(ref Frame top, byte b) case FK.StrEnum: return StepStrEnum(ref top, b); case FK.Num: return StepNum(ref top, b); case FK.Lit: return StepLit(ref top, b); + case FK.Free: return StepFree(ref top, b); default: return Step.Reject; } } + /// Free-value (issue #378): accept any single well-formed JSON value — a string, a bare + /// scalar, or a balanced {…}/[…] — and pop when it completes, so the enclosing object resumes and + /// keeps enforcing its declared/required keys. The value's contents are unconstrained. + private Step StepFree(ref Frame f, byte b) + { + switch (f.State) + { + case FrStart: + if (IsWs(b)) return Step.Consume; + if (b is (byte)'{' or (byte)'[') { f.FreeDepth = 1; f.State = FrBalanced; return Step.Consume; } + if (b == (byte)'"') { f.State = FrStr; return Step.Consume; } + if (b is (byte)',' or (byte)'}' or (byte)']') return Step.Reject; // a value can't be empty + f.State = FrBare; return Step.Consume; // bare scalar start + + case FrStr: + if (f.Escaped) { f.Escaped = false; return Step.Consume; } + if (b == (byte)'\\') { f.Escaped = true; return Step.Consume; } + if (b == (byte)'"') { _depth--; return PostValueOrDone(); } // string closes the value + return Step.Consume; + + case FrBare: + if (b is (byte)',' or (byte)'}' or (byte)']') { _depth--; return PostValueRetry(); } + return Step.Consume; + + case FrBalanced: + if (b == (byte)'"') { f.State = FrBalancedStr; return Step.Consume; } + if (b is (byte)'{' or (byte)'[') { f.FreeDepth++; return Step.Consume; } + if (b is (byte)'}' or (byte)']') + { + if (--f.FreeDepth == 0) { _depth--; return PostValueOrDone(); } + return Step.Consume; + } + return Step.Consume; + + case FrBalancedStr: + if (f.Escaped) { f.Escaped = false; return Step.Consume; } + if (b == (byte)'\\') { f.Escaped = true; return Step.Consume; } + if (b == (byte)'"') { f.State = FrBalanced; return Step.Consume; } + return Step.Consume; + + default: + return Step.Reject; + } + } + private static bool IsWs(byte b) => b is (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r'; // After a value frame pops, resume the parent (object/array) at its post-value state. @@ -817,6 +890,7 @@ private void CollectFirstBytes(int d, bool[] set) case FK.StrEnum: CollectStrEnum(ref f, set); break; case FK.Num: popThrough = CollectNum(ref f, set); break; case FK.Lit: popThrough = CollectLit(ref f, set); break; + case FK.Free: CollectFree(ref f, set); break; } if (!popThrough) break; d--; // bare value can end here — parent bytes also start a token @@ -874,6 +948,15 @@ private static void CollectStr(ref Frame f, bool[] set) for (int i = 0; i < 256; i++) set[i] = true; } + private static void CollectFree(ref Frame f, bool[] set) + { + // A free value admits almost anything — mark broadly and let the (fast-pathed) simulate pass + // decide. The only structural restriction is that the value can't START with a parent + // delimiter (that would be an empty value). + for (int i = 0; i < 256; i++) set[i] = true; + if (f.State == FrStart) { set[','] = false; set['}'] = false; set[']'] = false; } + } + private static void CollectStrEnum(ref Frame f, bool[] set) { if (f.State == SeExpectOpen) { MarkWs(set); set['"'] = true; return; } @@ -923,6 +1006,14 @@ private static void MarkValueFirstBytes(CompiledNode node, bool[] set) case JsonSchemaKind.Array: set['['] = true; break; case JsonSchemaKind.Object: set['{'] = true; break; case JsonSchemaKind.String: set['"'] = true; break; // JSON strings open with a '"' byte + case JsonSchemaKind.Any: + // A free array item (issue #378) can be any JSON value — mark every value-START byte. + // (Don't touch ']'; the array frame marks it for the empty-array close, and unsetting + // it here would wrongly forbid closing.) + set['"'] = true; set['{'] = true; set['['] = true; set['-'] = true; + MarkDigits(set); + set['t'] = true; set['f'] = true; set['n'] = true; // true / false / null + break; default: if (node.Literals is { } lits) foreach (var l in lits) { if (l.Length > 0) set[l[0]] = true; } diff --git a/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs b/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs index c0d729d..34d49a0 100644 --- a/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs +++ b/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs @@ -7,10 +7,10 @@ namespace SharpInference.Core.Grammar; // wire-format-agnostic — both the Gemma constraint (GemmaToolArgumentConstraint, bespoke // <|"|>-quoted syntax) and the JSON constraint (JsonToolArgumentConstraint, standard JSON for // Qwen/Llama/DeepSeek) consume the same CompiledObject/CompiledNode and only differ in how they -// walk the structural bytes. Only tools whose schema is FULLY constrainable (every value is a -// concrete type / typed array / nested typed object — no Any-typed value, no open object) are -// compiled; a tool that isn't is left out of the constraint and generates its arguments -// unconstrained, so the constraint never blocks generation. +// walk the structural bytes. A loosely-typed VALUE no longer disqualifies its tool (issue #378): it +// compiles to a FreeValue node the constraints accept as any well-formed value, so the surrounding +// structure stays enforced. A tool is left unconstrained only when its argument OBJECT itself is open +// (no declared properties) — the constraint then never blocks generation. /// Compiled value-type descriptor (see ). internal sealed class CompiledNode @@ -40,12 +40,24 @@ internal sealed class CompiledObject /// /// Compiles a parsed into the match tables the argument-grammar /// state machines drive. Shared by every architecture's constraint so the "which schemas are -/// constrainable" rule lives in exactly one place. Returns null for any schema that isn't -/// fully constrainable (open body, an Any-typed value, an untyped array, …) — the caller -/// then leaves that tool unconstrained. +/// constrainable" rule lives in exactly one place. +/// +/// +/// A loosely-typed VALUE (an Any-typed value with no type, an open object, or an +/// untyped array) no longer disqualifies the whole tool (issue #378): it compiles to the +/// node (), which the constraints accept as +/// any single well-formed value while still enforcing the object's structure — declared key +/// names, required-once, and the typed siblings. returns null +/// only when the object body ITSELF is open (no declared properties to enforce), so a partially-typed +/// tool stays constrained on its typed/required parts instead of being dropped wholesale. +/// /// internal static class ToolSchemaCompiler { + /// A fully-free value: any single well-formed value (string, scalar, array, object) the + /// constraints balance to completion without restricting its contents. Shared singleton. + public static CompiledNode FreeValue { get; } = new() { Kind = JsonSchemaKind.Any }; + // Depth-capped so compilation can't recurse unbounded — the parser already caps nesting, but the // ToolSchema records are public, so a caller that builds a deeply-nested schema in-memory (not via // the parser) would otherwise risk an uncatchable StackOverflow. Past the cap the tool is simply @@ -68,17 +80,20 @@ internal static class ToolSchemaCompiler var p = obj.Properties[i]; if (p.Name.Length == 0) return null; keys[i] = ToolSchema.Utf8(p.Name); - var node = TryCompileNode(p.Value, depth + 1); - if (node is null) return null; // a non-constrainable value → skip the tool - values[i] = node; + // A loosely-typed value compiles to FreeValue (issue #378) rather than disqualifying the + // tool — the key/required structure stays enforced, only the value is left free. + values[i] = CompileNode(p.Value, depth + 1); if (p.Required) reqMask |= 1UL << i; } return new CompiledObject { KeyBytes = keys, Values = values, RequiredMask = reqMask }; } - private static CompiledNode? TryCompileNode(ToolSchemaNode node, int depth) + /// Compiles one value node, degrading any loosely-typed value (Any / untyped array / + /// open or too-deep object) to rather than null — so a partially-typed + /// object still constrains its typed siblings (issue #378). + private static CompiledNode CompileNode(ToolSchemaNode node, int depth) { - if (depth >= MaxDepth) return null; + if (depth >= MaxDepth) return FreeValue; // too deep to constrain → free switch (node.Kind) { case JsonSchemaKind.String: @@ -103,18 +118,17 @@ internal static class ToolSchemaCompiler return new CompiledNode { Kind = JsonSchemaKind.Null, Literals = s_nullLiterals }; case JsonSchemaKind.Array: - // An untyped array (no items) isn't fully constrainable — skip the tool. - if (node.Items is null) return null; - var item = TryCompileNode(node.Items, depth + 1); - return item is null ? null : new CompiledNode { Kind = JsonSchemaKind.Array, Items = item }; + // An untyped array (no item shape) is left free; a typed array constrains its items. + if (node.Items is null) return FreeValue; + return new CompiledNode { Kind = JsonSchemaKind.Array, Items = CompileNode(node.Items, depth + 1) }; case JsonSchemaKind.Object: - if (node.Object is null) return null; - var obj = TryCompileObject(node.Object, depth + 1); - return obj is null ? null : new CompiledNode { Kind = JsonSchemaKind.Object, Object = obj }; + // An open / too-deep nested object is left free; a typed nested object recurses. + var obj = node.Object is null ? null : TryCompileObject(node.Object, depth + 1); + return obj is null ? FreeValue : new CompiledNode { Kind = JsonSchemaKind.Object, Object = obj }; default: - return null; // Any / unknown — not constrainable + return FreeValue; // Any / unknown — free value } } diff --git a/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs b/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs index ad8da20..1c254f9 100644 --- a/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs +++ b/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs @@ -261,6 +261,78 @@ public void DeepSeek_NameThenSeparatorEnvelope() Assert.False(Allowed(c, vocab, tok.Merged("{}"))); // required key still enforced } + [Fact] + public void PartiallyTyped_FreeValue_StillEnforcesTypedRequiredKey() + { + // 'context' is an open object (no properties) → a free value; 'location' stays a required + // string. Before #378 the whole tool was dropped (Build would return null); now it compiles. + var (c, tok, vocab) = Build( + """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}""", + "get_weather"); + + Feed(c, tok, QwenPreamble + "{"); + Assert.True(c.IsConstraining); + Assert.False(Allowed(c, vocab, tok.Char('}'))); // required 'location' still missing + + // Emit the loosely-typed 'context' first — its value may be an object, string, or bare scalar. + Feed(c, tok, "\"context\":"); + Assert.True(Allowed(c, vocab, tok.Char('{'))); + Assert.True(Allowed(c, vocab, tok.Char('"'))); + Assert.True(Allowed(c, vocab, tok.Char('5'))); + Assert.False(Allowed(c, vocab, tok.Char(','))); // a value can't be empty + + // A free object with arbitrary inner keys/nesting is accepted whole. + Feed(c, tok, "{\"anything\":42,\"nested\":{\"x\":[1,2]}}"); + Assert.True(c.IsConstraining); + Assert.False(Allowed(c, vocab, tok.Char('}'))); // 'location' STILL required after the free value + + Feed(c, tok, ",\"location\":\"Paris\""); + Assert.True(Allowed(c, vocab, tok.Char('}'))); // required satisfied → may close + Feed(c, tok, "}"); + Assert.False(c.IsConstraining); + } + + [Fact] + public void PartiallyTyped_AnyValue_AndUntypedArray_AreFree() + { + // 'meta' has no type (Any) and 'tags' is an untyped array — both free; 'id' stays required int. + var (c, tok, vocab) = Build( + """{"type":"object","properties":{"id":{"type":"integer"},"meta":{},"tags":{"type":"array"}},"required":["id"]}""", + "save"); + + Feed(c, tok, "{\"name\":\"save\",\"arguments\":{"); + Assert.True(c.IsConstraining); + + Feed(c, tok, "\"meta\":"); + Assert.True(Allowed(c, vocab, tok.Char('"'))); // Any → free: string ok + Assert.True(Allowed(c, vocab, tok.Char('['))); // …or array + Feed(c, tok, "\"x\",\"tags\":[1,\"a\",{\"k\":2}]"); // free string, then free untyped array + Assert.False(Allowed(c, vocab, tok.Char('}'))); // required 'id' still missing + Feed(c, tok, ",\"id\":7"); + Assert.True(Allowed(c, vocab, tok.Char('}'))); + } + + [Fact] + public void TypedArray_OfFreeItems_AcceptsAnyItemShape() + { + // A typed array whose ITEM type is loose ({}) — the array structure is enforced, each item is + // free. Regression: the first-byte prune must admit non-numeric free items (string/object), not + // only numbers. + var (c, tok, vocab) = Build( + """{"type":"object","properties":{"items":{"type":"array","items":{}}},"required":["items"]}""", + "save"); + + Feed(c, tok, "{\"name\":\"save\",\"arguments\":{\"items\":["); + Assert.True(c.IsConstraining); + Assert.True(Allowed(c, vocab, tok.Char('"'))); // string item + Assert.True(Allowed(c, vocab, tok.Char('{'))); // object item + Assert.True(Allowed(c, vocab, tok.Char('5'))); // number item + Assert.True(Allowed(c, vocab, tok.Char(']'))); // or close (empty) + + Feed(c, tok, "1,\"a\",{\"k\":2}]"); // mixed free items + Assert.True(Allowed(c, vocab, tok.Char('}'))); // array done, 'items' satisfied + } + [Fact] public void NonConstrainableTool_BuildsNoConstraint() { diff --git a/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs b/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs index 4106ff0..44527da 100644 --- a/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs +++ b/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs @@ -150,6 +150,28 @@ public void EnumValue_RestrictedToDeclaredSet() Assert.True(Allowed(c, tok, vocab.VocabSize, quote)); // completed value → close quote allowed } + [Fact] + public void PartiallyTyped_RequiredTypedKey_Enforced_LooseValueFree() + { + var tok = Tok(); + if (tok is null) { output.WriteLine("missing model — skip"); return; } + var vocab = new GrammarVocabulary(tok); + + // 'location' is a required string; 'context' is an open object (free value). Issue #378: the + // tool is now constrained on its typed/required parts instead of being dropped wholesale. + var schema = Schema("get_weather", + """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}"""); + var c = new Gemma4ToolCallAdapter().BuildArgumentConstraint([schema], vocab); + Assert.NotNull(c); + + Feed(c!, tok, "<|tool_call>call:get_weather{"); + Assert.True(c!.IsConstraining); + // Required 'location' not yet emitted → may not close; both declared keys reachable. + Assert.False(Allowed(c, tok, vocab.VocabSize, Id(tok, "}"))); + Assert.True(Allowed(c, tok, vocab.VocabSize, tok.Encode("location")[0])); + Assert.True(Allowed(c, tok, vocab.VocabSize, tok.Encode("context")[0])); + } + [Fact] public void NonGemmaAdapter_BuildsNoConstraint() { diff --git a/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs b/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs index b919a80..7ffaf3c 100644 --- a/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs +++ b/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs @@ -213,4 +213,60 @@ public void Reset_ReturnsToWatching() c.Reset(); Assert.False(c.IsConstraining); } + + [Fact] + public void TypedArray_OfFreeItems_AcceptsStringAndObjectItems() + { + // A typed array of loose items ({}): a free string item opens on the <|"|> quote, a free + // object on '{', a scalar bare. Regression for the array-item first-byte / quote path. + var (c, tok, vocab) = Build( + """{"type":"object","properties":{"items":{"type":"array","items":{}}},"required":["items"]}""", + "save"); + + Feed(c, tok, "<|tool_call>call:save{items:["); + Assert.True(c.IsConstraining); + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Quote)); // free string item opens on the quote + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('{'))); // free object item + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('5'))); // bare scalar item + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char(']'))); // or close (empty) + + c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "a"); c.Accept(FakeGemmaTokenizer.Quote); + Feed(c, tok, ",{k:3}]"); + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); + } + + [Fact] + public void PartiallyTyped_FreeValue_StillEnforcesTypedRequiredKey() + { + // 'context' is an open object → a free value; 'location' stays a required string. Before #378 + // the whole tool was dropped (Build would return null); now it compiles and enforces the + // typed/required parts while leaving 'context' free. + var (c, tok, vocab) = Build( + """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}""", + "get_weather"); + + Feed(c, tok, "<|tool_call>call:get_weather{"); + Assert.True(c.IsConstraining); + Assert.False(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // required 'location' missing + + // Emit the loosely-typed 'context' first — its value may be a string, object, array, or scalar. + Feed(c, tok, "context:"); + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Quote)); // <|"|> string + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('{'))); // object + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('['))); // array + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('3'))); // bare scalar + + // A free object with a <|"|>-string value and a nested array is balanced whole. + Feed(c, tok, "{a:"); + c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "x"); c.Accept(FakeGemmaTokenizer.Quote); + Feed(c, tok, ",b:[1,2]}"); + Assert.True(c.IsConstraining); + Assert.False(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // 'location' STILL required + + Feed(c, tok, ",location:"); + c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "Paris"); c.Accept(FakeGemmaTokenizer.Quote); + Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // required satisfied + c.Accept(FakeGemmaTokenizer.Char('}')); + Assert.False(c.IsConstraining); // clean close + } }