Add explicit LLM concurrency controls

This commit is contained in:
2026-05-12 21:17:35 +00:00
parent a48f6da1f4
commit 509436cc4a
19 changed files with 875 additions and 99 deletions

View File

@@ -121,7 +121,6 @@ Environment:
- `AUDITA_BASE_URL` - `AUDITA_BASE_URL`
- `AUDITA_LLM_TIMEOUT_SECONDS` - `AUDITA_LLM_TIMEOUT_SECONDS`
- `AUDITA_MAX_RETRIES` - `AUDITA_MAX_RETRIES`
- `AUDITA_LLM_CONCURRENCY`
CLI: CLI:
- `--llm-api-key` - `--llm-api-key`
@@ -129,7 +128,6 @@ CLI:
- `--base-url` - `--base-url`
- `--llm-timeout-seconds` - `--llm-timeout-seconds`
- `--max-retries` - `--max-retries`
- `--llm-concurrency`
### Validation LLM ### Validation LLM
@@ -151,9 +149,26 @@ CLI:
- `--validation-llm-concurrency` - `--validation-llm-concurrency`
- `--validation-max-prompt-tokens` - `--validation-max-prompt-tokens`
Validation concurrency behavior: ### LLM Concurrency
- when validation concurrency is unset, it inherits primary `llm-concurrency`
- when explicitly set, validation concurrency must be `<= llm-concurrency` Environment:
- `AUDITA_TOTAL_LLM_CONCURRENCY`
- `AUDITA_PROPOSAL_LLM_CONCURRENCY`
- `AUDITA_VALIDATION_LLM_CONCURRENCY`
- `AUDITA_LLM_CONCURRENCY` (legacy alias for `AUDITA_TOTAL_LLM_CONCURRENCY`)
CLI:
- `--total-llm-concurrency`
- `--proposal-llm-concurrency`
- `--validation-llm-concurrency`
- `--llm-concurrency` (legacy alias for `--total-llm-concurrency`)
Behavior:
- all proposal and validation LLM calls are bounded by total LLM concurrency
- proposal LLM calls are additionally bounded by proposal LLM concurrency
- when validation concurrency is unset, it inherits total LLM concurrency
- when explicitly set, proposal and validation concurrency must each be `<= total-llm-concurrency`
- canonical total settings win when both canonical and legacy alias settings are provided at the same precedence layer
### Confidence Thresholds ### Confidence Thresholds

View File

@@ -22,7 +22,7 @@ Implemented today:
- Deterministic validator-chain execution in the runner with cardinality enforcement. - Deterministic validator-chain execution in the runner with cardinality enforcement.
- Module-level validator decision/rejection reporting. - Module-level validator decision/rejection reporting.
- Internal structured LLM client contract plus an `instructor-go`-backed adapter package. - Internal structured LLM client contract plus an `instructor-go`-backed adapter package.
- Bounded LLM scheduler/semaphore infrastructure with context-aware permit handling. - Bounded FIFO LLM scheduler infrastructure with context-aware permit handling.
- Runtime primary/validation LLM effective-config resolution helpers with validation inheritance. - Runtime primary/validation LLM effective-config resolution helpers with validation inheritance.
- Generic JSON prompt/response diagnostics writer primitives with secret redaction. - Generic JSON prompt/response diagnostics writer primitives with secret redaction.
- LLM-backed validator models, prompt builders, batching, and runtime execution. - LLM-backed validator models, prompt builders, batching, and runtime execution.
@@ -162,6 +162,7 @@ Current runtime flow (`internal/cli/run.go`):
- default run path uses configured default sequence (`glossary,homophones,glossary,spoken_word,grammar`); - default run path uses configured default sequence (`glossary,homophones,glossary,spoken_word,grammar`);
- explicit `--modules` overrides the default sequence; - explicit `--modules` overrides the default sequence;
- test/injected module factory path remains available for deterministic runtime tests. - test/injected module factory path remains available for deterministic runtime tests.
- each module recomputes chunks from the current working transcript, runs chunk proposal work concurrently, aggregates deterministically, validates, and applies approved proposals once.
13. Output working transcript to `--output` file or stdout. 13. Output working transcript to `--output` file or stdout.
14. Build process report (`phase` currently set to `default_pipeline`). 14. Build process report (`phase` currently set to `default_pipeline`).
15. Optionally write `--report-json`; always write run-dir `report.json`. 15. Optionally write `--report-json`; always write run-dir `report.json`.
@@ -219,6 +220,7 @@ Precedence:
Implemented config surfaces include: Implemented config surfaces include:
- module list - module list
- primary and validation LLM settings - primary and validation LLM settings
- total/proposal/validation LLM concurrency controls
- section token controls and target sections - section token controls and target sections
- confidence thresholds - confidence thresholds
- normalization controls - normalization controls
@@ -246,10 +248,17 @@ Current runtime boundary:
- normal `go test ./...` does not require real LLM credentials or Python dependencies. - normal `go test ./...` does not require real LLM credentials or Python dependencies.
`internal/framework/llm` also provides: `internal/framework/llm` also provides:
- a bounded `Scheduler` for controlled concurrent LLM calls with reliable permit release; - a bounded FIFO `Scheduler` for controlled concurrent LLM calls with reliable permit release on success, error, and cancellation;
- primary/validation effective-config resolution helpers, including validation inheritance fallback to primary settings; - primary/validation effective-config resolution helpers, including validation inheritance fallback to total LLM concurrency settings;
- generic interaction diagnostics primitives that write machine-readable JSON artifacts for request metadata, request payload, response payload, and optional error payload with secret redaction. - generic interaction diagnostics primitives that write machine-readable JSON artifacts for request metadata, request payload, response payload, and optional error payload with secret redaction.
LLM concurrency runtime behavior:
- `total` concurrency bounds all proposal and validation LLM calls.
- `proposal` concurrency adds a proposal-only sub-cap, composed with total.
- `validation` concurrency adds a validation-only sub-cap, composed with total.
- legacy `llm-concurrency` inputs remain compatibility aliases for total concurrency.
- modules execute serially, chunk proposals run concurrently within each module, and approved proposals are applied once per module in deterministic order.
## Implemented normalization behavior ## Implemented normalization behavior
Normalization (`internal/core/normalization`) currently: Normalization (`internal/core/normalization`) currently:
- sorts by segment start time; - sorts by segment start time;

View File

@@ -20,6 +20,15 @@ Main gap versus the requested target architecture:
- global concurrency is currently represented by existing `--llm-concurrency`, - global concurrency is currently represented by existing `--llm-concurrency`,
- scheduler implementation is semaphore-based and does not explicitly guarantee FIFO ordering. - scheduler implementation is semaphore-based and does not explicitly guarantee FIFO ordering.
## Implementation Status (2026-05-12 Update)
The targeted concurrency gaps identified in this audit have now been addressed:
- explicit total/proposal/validation LLM concurrency controls are implemented in config/env/CLI,
- legacy `llm-concurrency` settings are preserved as compatibility aliases to total concurrency,
- proposal and validation schedulers are composed with a global total-cap scheduler,
- scheduler default behavior is FIFO with context-aware queued cancellation and reliable permit release,
- runner proposal worker fan-out is aligned with effective proposal concurrency.
## Audit Findings (Questions 1-14) ## Audit Findings (Questions 1-14)
1. **Does the current runner execute modules serially?** 1. **Does the current runner execute modules serially?**

View File

@@ -16,6 +16,7 @@ audita process <transcript.json> \
Recommended additions: Recommended additions:
- `--work-dir <dir>` to control diagnostics location. - `--work-dir <dir>` to control diagnostics location.
- `--work-dir-retention <always|auto|never>` to control retained run directories. - `--work-dir-retention <always|auto|never>` to control retained run directories.
- `--total-llm-concurrency`, `--proposal-llm-concurrency`, and `--validation-llm-concurrency` when orchestration needs explicit LLM throughput controls.
- `--modules ...` only when intentionally overriding the default full sequence. - `--modules ...` only when intentionally overriding the default full sequence.
## Stdout behavior ## Stdout behavior

View File

@@ -196,8 +196,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
} }
globalScheduler := proposalScheduler globalScheduler := proposalScheduler
if globalScheduler == nil { if globalScheduler == nil {
primaryCfg := llm.ResolvePrimaryConfig(inv.Config) s, sErr := llm.NewScheduler(inv.Config.TotalLLMConcurrency)
s, sErr := llm.NewScheduler(primaryCfg.Concurrency)
if sErr != nil { if sErr != nil {
return fail("runner_setup", sErr, nil) return fail("runner_setup", sErr, nil)
} }
@@ -205,12 +204,18 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
} }
if proposalScheduler == nil { if proposalScheduler == nil {
proposalScheduler = globalScheduler proposalScheduler = globalScheduler
if inv.Config.EffectiveProposalLLMConcurrency() < inv.Config.TotalLLMConcurrency {
s, sErr := llm.NewScheduler(inv.Config.EffectiveProposalLLMConcurrency())
if sErr != nil {
return fail("runner_setup", sErr, nil)
}
proposalScheduler = composeSchedulers(globalScheduler, s)
}
} }
if validationScheduler == nil { if validationScheduler == nil {
validationScheduler = globalScheduler validationScheduler = globalScheduler
if inv.Config.ValidationLLM.Concurrency != nil && inv.Config.PrimaryLLM.Concurrency > inv.Config.EffectiveValidationLLMConfig().Concurrency { if inv.Config.ValidationLLMConcurrency != nil && inv.Config.EffectiveValidationLLMConcurrency() < inv.Config.TotalLLMConcurrency {
validationCfg := llm.ResolveValidationConfig(inv.Config) s, sErr := llm.NewScheduler(inv.Config.EffectiveValidationLLMConcurrency())
s, sErr := llm.NewScheduler(validationCfg.Concurrency)
if sErr != nil { if sErr != nil {
return fail("runner_setup", sErr, nil) return fail("runner_setup", sErr, nil)
} }
@@ -392,6 +397,10 @@ func runProcess(args []string, stdout, stderr io.Writer) int {
overrides.ValidationBaseURL = pFlags.validationBaseURL overrides.ValidationBaseURL = pFlags.validationBaseURL
case "llm-timeout-seconds": case "llm-timeout-seconds":
overrides.PrimaryLLMTimeoutSeconds = pFlags.llmTimeoutSeconds overrides.PrimaryLLMTimeoutSeconds = pFlags.llmTimeoutSeconds
case "total-llm-concurrency":
overrides.TotalLLMConcurrency = pFlags.totalLLMConcurrency
case "proposal-llm-concurrency":
overrides.ProposalLLMConcurrency = pFlags.proposalLLMConcurrency
case "llm-concurrency": case "llm-concurrency":
overrides.PrimaryLLMConcurrency = pFlags.llmConcurrency overrides.PrimaryLLMConcurrency = pFlags.llmConcurrency
case "validation-llm-timeout-seconds": case "validation-llm-timeout-seconds":
@@ -682,6 +691,8 @@ type processFlags struct {
baseURL *string baseURL *string
validationBaseURL *string validationBaseURL *string
llmTimeoutSeconds *int llmTimeoutSeconds *int
totalLLMConcurrency *int
proposalLLMConcurrency *int
llmConcurrency *int llmConcurrency *int
validationLLMTimeoutSeconds *int validationLLMTimeoutSeconds *int
validationMaxPromptTokens *int validationMaxPromptTokens *int
@@ -717,9 +728,9 @@ func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, proc
validationMaxRetriesDefault = *cfg.ValidationLLM.MaxRetries validationMaxRetriesDefault = *cfg.ValidationLLM.MaxRetries
} }
validationLLMConcurrencyDefault := cfg.PrimaryLLM.Concurrency validationLLMConcurrencyDefault := cfg.TotalLLMConcurrency
if cfg.ValidationLLM.Concurrency != nil { if cfg.ValidationLLMConcurrency != nil {
validationLLMConcurrencyDefault = *cfg.ValidationLLM.Concurrency validationLLMConcurrencyDefault = *cfg.ValidationLLMConcurrency
} }
targetSectionsDefault := 0 targetSectionsDefault := 0
@@ -739,13 +750,15 @@ func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, proc
baseURL: fs.String("base-url", cfg.PrimaryLLM.BaseURL, "Primary OpenAI-compatible base URL"), baseURL: fs.String("base-url", cfg.PrimaryLLM.BaseURL, "Primary OpenAI-compatible base URL"),
validationBaseURL: fs.String("validation-base-url", cfg.ValidationLLM.BaseURL, "Validation OpenAI-compatible base URL"), validationBaseURL: fs.String("validation-base-url", cfg.ValidationLLM.BaseURL, "Validation OpenAI-compatible base URL"),
llmTimeoutSeconds: fs.Int("llm-timeout-seconds", cfg.PrimaryLLM.TimeoutSeconds, "Primary LLM timeout in seconds"), llmTimeoutSeconds: fs.Int("llm-timeout-seconds", cfg.PrimaryLLM.TimeoutSeconds, "Primary LLM timeout in seconds"),
llmConcurrency: fs.Int("llm-concurrency", cfg.PrimaryLLM.Concurrency, "Primary LLM concurrency"), totalLLMConcurrency: fs.Int("total-llm-concurrency", cfg.TotalLLMConcurrency, "Total concurrent LLM calls across proposal and validation"),
proposalLLMConcurrency: fs.Int("proposal-llm-concurrency", cfg.EffectiveProposalLLMConcurrency(), "Concurrent proposal-generation LLM calls"),
llmConcurrency: fs.Int("llm-concurrency", cfg.TotalLLMConcurrency, "Alias for --total-llm-concurrency"),
validationLLMTimeoutSeconds: fs.Int("validation-llm-timeout-seconds", validationTimeoutSecondsDefault, "Validation LLM timeout in seconds"), validationLLMTimeoutSeconds: fs.Int("validation-llm-timeout-seconds", validationTimeoutSecondsDefault, "Validation LLM timeout in seconds"),
validationMaxPromptTokens: fs.Int("validation-max-prompt-tokens", cfg.ValidationMaxPromptTokens, "Validation max prompt tokens"), validationMaxPromptTokens: fs.Int("validation-max-prompt-tokens", cfg.ValidationMaxPromptTokens, "Validation max prompt tokens"),
targetSections: fs.Int("target-sections", targetSectionsDefault, "Target number of transcript sections"), targetSections: fs.Int("target-sections", targetSectionsDefault, "Target number of transcript sections"),
maxRetries: fs.Int("max-retries", cfg.PrimaryLLM.MaxRetries, "Maximum structured-output retries"), maxRetries: fs.Int("max-retries", cfg.PrimaryLLM.MaxRetries, "Maximum structured-output retries"),
validationMaxRetries: fs.Int("validation-max-retries", validationMaxRetriesDefault, "Validation structured-output retries"), validationMaxRetries: fs.Int("validation-max-retries", validationMaxRetriesDefault, "Validation structured-output retries"),
validationLLMConcurrency: fs.Int("validation-llm-concurrency", validationLLMConcurrencyDefault, "Validation LLM concurrency"), validationLLMConcurrency: fs.Int("validation-llm-concurrency", validationLLMConcurrencyDefault, "Concurrent validation LLM calls (inherits total when unset)"),
maxSectionTokens: fs.Int("max-section-tokens", cfg.MaxSectionTokens, "Maximum section tokens"), maxSectionTokens: fs.Int("max-section-tokens", cfg.MaxSectionTokens, "Maximum section tokens"),
minSectionTokens: fs.Int("min-section-tokens", cfg.MinSectionTokens, "Minimum section tokens"), minSectionTokens: fs.Int("min-section-tokens", cfg.MinSectionTokens, "Minimum section tokens"),
glossaryConfidenceThreshold: fs.Float64("glossary-confidence-threshold", cfg.Thresholds.Glossary, "Glossary confidence threshold"), glossaryConfidenceThreshold: fs.Float64("glossary-confidence-threshold", cfg.Thresholds.Glossary, "Glossary confidence threshold"),

View File

@@ -63,6 +63,8 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) {
"--base-url", "--base-url",
"--validation-base-url", "--validation-base-url",
"--llm-timeout-seconds", "--llm-timeout-seconds",
"--total-llm-concurrency",
"--proposal-llm-concurrency",
"--llm-concurrency", "--llm-concurrency",
"--validation-llm-timeout-seconds", "--validation-llm-timeout-seconds",
"--validation-max-prompt-tokens", "--validation-max-prompt-tokens",
@@ -246,7 +248,7 @@ func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
} }
} }
func TestRunProcessRejectsValidationConcurrencyAbovePrimaryConcurrency(t *testing.T) { func TestRunProcessRejectsValidationConcurrencyAboveTotalConcurrency(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -259,7 +261,7 @@ func TestRunProcessRejectsValidationConcurrencyAbovePrimaryConcurrency(t *testin
transcriptPath, transcriptPath,
"--glossary", "--glossary",
fixturePath("tiny_glossary.yaml"), fixturePath("tiny_glossary.yaml"),
"--llm-concurrency", "--total-llm-concurrency",
"1", "1",
"--validation-llm-concurrency", "--validation-llm-concurrency",
"2", "2",
@@ -273,12 +275,12 @@ func TestRunProcessRejectsValidationConcurrencyAbovePrimaryConcurrency(t *testin
if !strings.Contains(stderr.String(), "invalid CLI configuration") { if !strings.Contains(stderr.String(), "invalid CLI configuration") {
t.Fatalf("expected invalid CLI configuration error, got %q", stderr.String()) t.Fatalf("expected invalid CLI configuration error, got %q", stderr.String())
} }
if !strings.Contains(stderr.String(), "validation llm concurrency must be less than or equal to primary llm concurrency") { if !strings.Contains(stderr.String(), "validation llm concurrency must be less than or equal to total llm concurrency") {
t.Fatalf("expected validation/primary concurrency error details, got %q", stderr.String()) t.Fatalf("expected validation/total concurrency error details, got %q", stderr.String())
} }
} }
func TestRunProcessLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t *testing.T) { func TestRunProcessTotalLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{ "m": fakeModule{
key: "m", key: "m",
@@ -288,14 +290,96 @@ func TestRunProcessLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t
if req.Config == nil { if req.Config == nil {
t.Fatal("expected config in validation request") t.Fatal("expected config in validation request")
} }
if req.Config.PrimaryLLM.Concurrency != 4 { if req.Config.TotalLLMConcurrency != 4 {
t.Fatalf("expected primary llm concurrency 4, got %d", req.Config.PrimaryLLM.Concurrency) t.Fatalf("expected total llm concurrency 4, got %d", req.Config.TotalLLMConcurrency)
} }
if req.Config.ValidationLLM.Concurrency != nil { if req.Config.ProposalLLMConcurrency != 4 {
t.Fatalf("expected validation concurrency unset, got %#v", req.Config.ValidationLLM.Concurrency) t.Fatalf("expected proposal llm concurrency to inherit total 4, got %d", req.Config.ProposalLLMConcurrency)
} }
if req.Config.EffectiveValidationLLMConfig().Concurrency != 4 { if req.Config.ValidationLLMConcurrency != nil {
t.Fatalf("expected inherited effective validation concurrency 4, got %d", req.Config.EffectiveValidationLLMConfig().Concurrency) t.Fatalf("expected validation concurrency unset, got %#v", req.Config.ValidationLLMConcurrency)
}
if req.Config.EffectiveValidationLLMConcurrency() != 4 {
t.Fatalf("expected inherited effective validation concurrency 4, got %d", req.Config.EffectiveValidationLLMConcurrency())
}
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}},
},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
return nil, nil
},
},
}}
t.Cleanup(func() { processModuleFactory = nil })
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := writeFile(t, "transcript.json", `[
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
]`)
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--modules",
"m",
"--total-llm-concurrency",
"4",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessRejectsProposalConcurrencyAboveTotalConcurrency(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := writeFile(t, "transcript.json", `[
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
]`)
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--total-llm-concurrency",
"1",
"--proposal-llm-concurrency",
"2",
}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected nonzero exit code for invalid llm concurrency combination")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "invalid CLI configuration") {
t.Fatalf("expected invalid CLI configuration error, got %q", stderr.String())
}
if !strings.Contains(stderr.String(), "proposal llm concurrency must be less than or equal to total llm concurrency") {
t.Fatalf("expected proposal/total concurrency error details, got %q", stderr.String())
}
}
func TestRunProcessLegacyLLMConcurrencyAliasSetsTotalAndProposal(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{
key: "m",
policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
if req.Config == nil {
t.Fatal("expected config in validation request")
}
if req.Config.TotalLLMConcurrency != 3 {
t.Fatalf("expected total llm concurrency 3, got %d", req.Config.TotalLLMConcurrency)
}
if req.Config.ProposalLLMConcurrency != 3 {
t.Fatalf("expected proposal llm concurrency inherited from alias, got %d", req.Config.ProposalLLMConcurrency)
} }
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}}, }},
@@ -321,7 +405,62 @@ func TestRunProcessLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t
"--modules", "--modules",
"m", "m",
"--llm-concurrency", "--llm-concurrency",
"3",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
}
}
func TestRunProcessLLMConcurrencyFlagsOverrideEnvironment(t *testing.T) {
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{
key: "m",
policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
if req.Config == nil {
t.Fatal("expected config in validation request")
}
if req.Config.TotalLLMConcurrency != 4 {
t.Fatalf("expected cli total llm concurrency 4, got %d", req.Config.TotalLLMConcurrency)
}
if req.Config.ProposalLLMConcurrency != 3 {
t.Fatalf("expected cli proposal llm concurrency 3, got %d", req.Config.ProposalLLMConcurrency)
}
if req.Config.ValidationLLMConcurrency == nil || *req.Config.ValidationLLMConcurrency != 2 {
t.Fatalf("expected env validation llm concurrency 2, got %#v", req.Config.ValidationLLMConcurrency)
}
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
}},
},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
return nil, nil
},
},
}}
t.Cleanup(func() { processModuleFactory = nil })
t.Setenv("AUDITA_TOTAL_LLM_CONCURRENCY", "2")
t.Setenv("AUDITA_PROPOSAL_LLM_CONCURRENCY", "2")
t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2")
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := writeFile(t, "transcript.json", `[
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
]`)
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--modules",
"m",
"--total-llm-concurrency",
"4", "4",
"--proposal-llm-concurrency",
"3",
}, &stdout, &stderr) }, &stdout, &stderr)
if exitCode != 0 { if exitCode != 0 {
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
@@ -373,6 +512,60 @@ func TestComposeSchedulersEnforcesStricterSubcap(t *testing.T) {
} }
} }
func TestComposedProposalAndValidationSchedulersShareGlobalTotalCap(t *testing.T) {
global, err := llm.NewScheduler(2)
if err != nil {
t.Fatalf("NewScheduler(global): %v", err)
}
proposalSubcap, err := llm.NewScheduler(2)
if err != nil {
t.Fatalf("NewScheduler(proposalSubcap): %v", err)
}
validationSubcap, err := llm.NewScheduler(2)
if err != nil {
t.Fatalf("NewScheduler(validationSubcap): %v", err)
}
proposalScheduler := composeSchedulers(global, proposalSubcap)
validationScheduler := composeSchedulers(global, validationSubcap)
var inFlight int32
var maxInFlight int32
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
scheduler := proposalScheduler
if idx%2 == 1 {
scheduler = validationScheduler
}
runErr := scheduler.Run(context.Background(), func(context.Context) error {
current := atomic.AddInt32(&inFlight, 1)
for {
prior := atomic.LoadInt32(&maxInFlight)
if current <= prior || atomic.CompareAndSwapInt32(&maxInFlight, prior, current) {
break
}
}
time.Sleep(20 * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
return nil
})
if runErr != nil {
t.Errorf("scheduler run error: %v", runErr)
}
}(i)
}
wg.Wait()
if maxInFlight > 2 {
t.Fatalf("expected combined proposal+validation concurrency <= global total cap (2), got %d", maxInFlight)
}
if maxInFlight < 2 {
t.Fatalf("expected observed combined concurrency of at least 2, got %d", maxInFlight)
}
}
func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) { func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -2694,6 +2887,12 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) {
fixturePath("tiny_transcript.json"), fixturePath("tiny_transcript.json"),
"--glossary", "--glossary",
fixturePath("tiny_glossary.yaml"), fixturePath("tiny_glossary.yaml"),
"--total-llm-concurrency",
"3",
"--proposal-llm-concurrency",
"2",
"--validation-llm-concurrency",
"1",
"--output", "--output",
outputPath, outputPath,
"--report-json", "--report-json",
@@ -2736,6 +2935,24 @@ func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) {
t.Fatalf("effective config artifact leaked API key material") t.Fatalf("effective config artifact leaked API key material")
} }
var effectiveConfig struct {
TotalLLMConcurrency int `json:"TotalLLMConcurrency"`
ProposalLLMConcurrency int `json:"ProposalLLMConcurrency"`
ValidationLLMConcurrency *int `json:"ValidationLLMConcurrency"`
}
if err := json.Unmarshal(configBytes, &effectiveConfig); err != nil {
t.Fatalf("failed to parse effective config metadata: %v", err)
}
if effectiveConfig.TotalLLMConcurrency != 3 {
t.Fatalf("expected total_llm_concurrency=3 in effective config, got %d", effectiveConfig.TotalLLMConcurrency)
}
if effectiveConfig.ProposalLLMConcurrency != 2 {
t.Fatalf("expected proposal_llm_concurrency=2 in effective config, got %d", effectiveConfig.ProposalLLMConcurrency)
}
if effectiveConfig.ValidationLLMConcurrency == nil || *effectiveConfig.ValidationLLMConcurrency != 1 {
t.Fatalf("expected validation_llm_concurrency=1 in effective config, got %#v", effectiveConfig.ValidationLLMConcurrency)
}
var invocation struct { var invocation struct {
Operation string `json:"operation"` Operation string `json:"operation"`
TranscriptPath string `json:"transcript_path"` TranscriptPath string `json:"transcript_path"`

View File

@@ -36,6 +36,9 @@ type Config struct {
Modules []string Modules []string
PrimaryLLM LLMConfig PrimaryLLM LLMConfig
ValidationLLM ValidationLLMConfig ValidationLLM ValidationLLMConfig
TotalLLMConcurrency int
ProposalLLMConcurrency int
ValidationLLMConcurrency *int
ValidationMaxPromptTokens int ValidationMaxPromptTokens int
MaxSectionTokens int MaxSectionTokens int
MinSectionTokens int MinSectionTokens int
@@ -52,6 +55,8 @@ type LLMConfig struct {
BaseURL string BaseURL string
TimeoutSeconds int TimeoutSeconds int
MaxRetries int MaxRetries int
// Concurrency is retained as a backward-compatible alias for
// TotalLLMConcurrency.
Concurrency int Concurrency int
} }
@@ -61,6 +66,8 @@ type ValidationLLMConfig struct {
BaseURL string BaseURL string
TimeoutSeconds *int TimeoutSeconds *int
MaxRetries *int MaxRetries *int
// Concurrency is retained as a backward-compatible alias for
// ValidationLLMConcurrency.
Concurrency *int Concurrency *int
} }
@@ -91,6 +98,9 @@ func Default() Config {
Concurrency: DefaultLLMConcurrency, Concurrency: DefaultLLMConcurrency,
}, },
ValidationLLM: ValidationLLMConfig{}, ValidationLLM: ValidationLLMConfig{},
TotalLLMConcurrency: DefaultLLMConcurrency,
ProposalLLMConcurrency: DefaultLLMConcurrency,
ValidationLLMConcurrency: nil,
ValidationMaxPromptTokens: DefaultValidationMaxPromptTokens, ValidationMaxPromptTokens: DefaultValidationMaxPromptTokens,
MaxSectionTokens: DefaultMaxSectionTokens, MaxSectionTokens: DefaultMaxSectionTokens,
MinSectionTokens: DefaultMinSectionTokens, MinSectionTokens: DefaultMinSectionTokens,
@@ -146,9 +156,37 @@ func (c Config) EffectiveValidationLLMConfig() LLMConfig {
if c.ValidationLLM.MaxRetries != nil { if c.ValidationLLM.MaxRetries != nil {
effective.MaxRetries = *c.ValidationLLM.MaxRetries effective.MaxRetries = *c.ValidationLLM.MaxRetries
} }
if c.ValidationLLM.Concurrency != nil { effective.Concurrency = c.EffectiveValidationLLMConcurrency()
effective.Concurrency = *c.ValidationLLM.Concurrency
}
return effective return effective
} }
func (c Config) EffectiveValidationLLMConcurrency() int {
if c.ValidationLLMConcurrency != nil {
return *c.ValidationLLMConcurrency
}
return c.TotalLLMConcurrency
}
func (c Config) EffectiveProposalLLMConcurrency() int {
if c.ProposalLLMConcurrency > 0 {
return c.ProposalLLMConcurrency
}
return c.TotalLLMConcurrency
}
func (c *Config) syncLegacyConcurrencyAliases() {
if c == nil {
return
}
c.PrimaryLLM.Concurrency = c.TotalLLMConcurrency
c.ValidationLLM.Concurrency = intPtr(c.ValidationLLMConcurrency)
}
func intPtr(v *int) *int {
if v == nil {
return nil
}
x := *v
return &x
}

View File

@@ -24,8 +24,17 @@ func TestDefaultConfigValues(t *testing.T) {
if cfg.PrimaryLLM.MaxRetries != DefaultMaxRetries { if cfg.PrimaryLLM.MaxRetries != DefaultMaxRetries {
t.Fatalf("unexpected default max retries: %d", cfg.PrimaryLLM.MaxRetries) t.Fatalf("unexpected default max retries: %d", cfg.PrimaryLLM.MaxRetries)
} }
if cfg.PrimaryLLM.Concurrency != DefaultLLMConcurrency { if cfg.TotalLLMConcurrency != DefaultLLMConcurrency {
t.Fatalf("unexpected default llm concurrency: %d", cfg.PrimaryLLM.Concurrency) t.Fatalf("unexpected default total llm concurrency: %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != DefaultLLMConcurrency {
t.Fatalf("unexpected default proposal llm concurrency: %d", cfg.ProposalLLMConcurrency)
}
if cfg.ValidationLLMConcurrency != nil {
t.Fatalf("expected validation llm concurrency to be unset by default")
}
if cfg.PrimaryLLM.Concurrency != cfg.TotalLLMConcurrency {
t.Fatalf("expected primary llm concurrency alias to mirror total, got primary=%d total=%d", cfg.PrimaryLLM.Concurrency, cfg.TotalLLMConcurrency)
} }
if cfg.ValidationLLM.TimeoutSeconds != nil { if cfg.ValidationLLM.TimeoutSeconds != nil {
t.Fatalf("expected validation timeout to be unset by default") t.Fatalf("expected validation timeout to be unset by default")
@@ -34,7 +43,7 @@ func TestDefaultConfigValues(t *testing.T) {
t.Fatalf("expected validation max retries to be unset by default") t.Fatalf("expected validation max retries to be unset by default")
} }
if cfg.ValidationLLM.Concurrency != nil { if cfg.ValidationLLM.Concurrency != nil {
t.Fatalf("expected validation llm concurrency to be unset by default") t.Fatalf("expected legacy validation llm concurrency alias to be unset by default")
} }
if cfg.TargetSections != nil { if cfg.TargetSections != nil {
t.Fatalf("expected target sections to be unset by default") t.Fatalf("expected target sections to be unset by default")
@@ -56,7 +65,8 @@ func TestLoadFromEnvOverridesAndFallback(t *testing.T) {
"AUDITA_BASE_URL": "https://api.openai.com/v1", "AUDITA_BASE_URL": "https://api.openai.com/v1",
"AUDITA_LLM_TIMEOUT_SECONDS": "120", "AUDITA_LLM_TIMEOUT_SECONDS": "120",
"AUDITA_MAX_RETRIES": "7", "AUDITA_MAX_RETRIES": "7",
"AUDITA_LLM_CONCURRENCY": "6", "AUDITA_TOTAL_LLM_CONCURRENCY": "6",
"AUDITA_PROPOSAL_LLM_CONCURRENCY": "4",
"AUDITA_VALIDATION_LLM_CONCURRENCY": "2", "AUDITA_VALIDATION_LLM_CONCURRENCY": "2",
"AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "4096", "AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "4096",
"AUDITA_MAX_SECTION_TOKENS": "9000", "AUDITA_MAX_SECTION_TOKENS": "9000",
@@ -92,17 +102,63 @@ func TestLoadFromEnvOverridesAndFallback(t *testing.T) {
if cfg.TargetSections == nil || *cfg.TargetSections != 5 { if cfg.TargetSections == nil || *cfg.TargetSections != 5 {
t.Fatalf("unexpected target sections: %#v", cfg.TargetSections) t.Fatalf("unexpected target sections: %#v", cfg.TargetSections)
} }
if cfg.TotalLLMConcurrency != 6 {
t.Fatalf("unexpected total llm concurrency: %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 4 {
t.Fatalf("unexpected proposal llm concurrency: %d", cfg.ProposalLLMConcurrency)
}
if cfg.ValidationLLMConcurrency == nil || *cfg.ValidationLLMConcurrency != 2 {
t.Fatalf("unexpected validation llm concurrency: %#v", cfg.ValidationLLMConcurrency)
}
if cfg.PrimaryLLM.Concurrency != 6 { if cfg.PrimaryLLM.Concurrency != 6 {
t.Fatalf("unexpected primary llm concurrency: %d", cfg.PrimaryLLM.Concurrency) t.Fatalf("expected primary alias concurrency 6, got %d", cfg.PrimaryLLM.Concurrency)
} }
if cfg.ValidationLLM.Concurrency == nil || *cfg.ValidationLLM.Concurrency != 2 { if cfg.ValidationLLM.Concurrency == nil || *cfg.ValidationLLM.Concurrency != 2 {
t.Fatalf("unexpected validation llm concurrency: %#v", cfg.ValidationLLM.Concurrency) t.Fatalf("expected validation alias concurrency 2, got %#v", cfg.ValidationLLM.Concurrency)
} }
if cfg.WorkDirRetention != WorkDirRetentionAlways { if cfg.WorkDirRetention != WorkDirRetentionAlways {
t.Fatalf("unexpected work dir retention: %q", cfg.WorkDirRetention) t.Fatalf("unexpected work dir retention: %q", cfg.WorkDirRetention)
} }
} }
func TestLoadFromEnvLegacyLLMConcurrencyAliasForTotalAndProposal(t *testing.T) {
env := map[string]string{
"AUDITA_LLM_CONCURRENCY": "5",
}
cfg, err := loadFromLookup(mapLookup(env))
if err != nil {
t.Fatalf("loadFromLookup returned error: %v", err)
}
if cfg.TotalLLMConcurrency != 5 {
t.Fatalf("expected total concurrency from legacy alias, got %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 5 {
t.Fatalf("expected proposal concurrency to inherit legacy total, got %d", cfg.ProposalLLMConcurrency)
}
}
func TestLoadFromEnvCanonicalTotalWinsLegacyAlias(t *testing.T) {
env := map[string]string{
"AUDITA_TOTAL_LLM_CONCURRENCY": "4",
"AUDITA_LLM_CONCURRENCY": "9",
}
cfg, err := loadFromLookup(mapLookup(env))
if err != nil {
t.Fatalf("loadFromLookup returned error: %v", err)
}
if cfg.TotalLLMConcurrency != 4 {
t.Fatalf("expected canonical total to win over legacy alias, got %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 4 {
t.Fatalf("expected proposal to inherit canonical total when unset, got %d", cfg.ProposalLLMConcurrency)
}
}
func TestLoadFromEnvUsesAuditaLLMAPIKeyOverFallback(t *testing.T) { func TestLoadFromEnvUsesAuditaLLMAPIKeyOverFallback(t *testing.T) {
env := map[string]string{ env := map[string]string{
"AUDITA_LLM_API_KEY": "primary-key", "AUDITA_LLM_API_KEY": "primary-key",
@@ -127,12 +183,14 @@ func TestApplyCLIOverridesPrecedence(t *testing.T) {
model := "cli-model" model := "cli-model"
workDir := "/cli/work" workDir := "/cli/work"
modules := "grammar" modules := "grammar"
llmConcurrency := 5 totalLLMConcurrency := 5
proposalLLMConcurrency := 3
overrides := CLIOverrides{ overrides := CLIOverrides{
PrimaryModel: &model, PrimaryModel: &model,
WorkDir: &workDir, WorkDir: &workDir,
ModulesCSV: &modules, ModulesCSV: &modules,
PrimaryLLMConcurrency: &llmConcurrency, TotalLLMConcurrency: &totalLLMConcurrency,
ProposalLLMConcurrency: &proposalLLMConcurrency,
} }
if err := cfg.ApplyCLIOverrides(overrides); err != nil { if err := cfg.ApplyCLIOverrides(overrides); err != nil {
@@ -148,17 +206,54 @@ func TestApplyCLIOverridesPrecedence(t *testing.T) {
if !reflect.DeepEqual(cfg.Modules, []string{"grammar"}) { if !reflect.DeepEqual(cfg.Modules, []string{"grammar"}) {
t.Fatalf("unexpected modules: %#v", cfg.Modules) t.Fatalf("unexpected modules: %#v", cfg.Modules)
} }
if cfg.PrimaryLLM.Concurrency != 5 { if cfg.TotalLLMConcurrency != 5 {
t.Fatalf("expected CLI llm concurrency override, got %d", cfg.PrimaryLLM.Concurrency) t.Fatalf("expected CLI total concurrency override, got %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 3 {
t.Fatalf("expected CLI proposal concurrency override, got %d", cfg.ProposalLLMConcurrency)
}
}
func TestApplyCLIOverridesLegacyLLMConcurrencyAlias(t *testing.T) {
cfg := Default()
aliasConcurrency := 6
if err := cfg.ApplyCLIOverrides(CLIOverrides{PrimaryLLMConcurrency: &aliasConcurrency}); err != nil {
t.Fatalf("ApplyCLIOverrides failed: %v", err)
}
if cfg.TotalLLMConcurrency != 6 {
t.Fatalf("expected legacy --llm-concurrency alias to set total, got %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 6 {
t.Fatalf("expected proposal to inherit aliased total when unset, got %d", cfg.ProposalLLMConcurrency)
}
}
func TestApplyCLIOverridesCanonicalTotalWinsLegacyAlias(t *testing.T) {
cfg := Default()
canonicalTotal := 4
legacyAlias := 9
if err := cfg.ApplyCLIOverrides(CLIOverrides{TotalLLMConcurrency: &canonicalTotal, PrimaryLLMConcurrency: &legacyAlias}); err != nil {
t.Fatalf("ApplyCLIOverrides failed: %v", err)
}
if cfg.TotalLLMConcurrency != 4 {
t.Fatalf("expected canonical total concurrency to win, got %d", cfg.TotalLLMConcurrency)
}
if cfg.ProposalLLMConcurrency != 4 {
t.Fatalf("expected proposal to inherit canonical total when proposal is unset, got %d", cfg.ProposalLLMConcurrency)
} }
} }
func TestValidationFailures(t *testing.T) { func TestValidationFailures(t *testing.T) {
cfg := Default() cfg := Default()
cfg.PrimaryLLM.TimeoutSeconds = -1 cfg.PrimaryLLM.TimeoutSeconds = -1
cfg.PrimaryLLM.Concurrency = 0 cfg.TotalLLMConcurrency = 0
cfg.ProposalLLMConcurrency = 0
validationConcurrency := 5 validationConcurrency := 5
cfg.ValidationLLM.Concurrency = &validationConcurrency cfg.ValidationLLMConcurrency = &validationConcurrency
cfg.ValidationMaxPromptTokens = 0 cfg.ValidationMaxPromptTokens = 0
cfg.MaxSectionTokens = 100 cfg.MaxSectionTokens = 100
cfg.MinSectionTokens = 200 cfg.MinSectionTokens = 200
@@ -173,8 +268,9 @@ func TestValidationFailures(t *testing.T) {
message := err.Error() message := err.Error()
for _, expected := range []string{ for _, expected := range []string{
"primary llm timeout seconds", "primary llm timeout seconds",
"primary llm concurrency", "total llm concurrency",
"validation llm concurrency must be less than or equal to primary llm concurrency", "proposal llm concurrency",
"validation llm concurrency must be less than or equal to total llm concurrency",
"validation max prompt tokens", "validation max prompt tokens",
"min section tokens", "min section tokens",
"grammar confidence threshold", "grammar confidence threshold",
@@ -193,7 +289,8 @@ func TestEffectiveValidationLLMInheritance(t *testing.T) {
cfg.PrimaryLLM.BaseURL = "https://primary.example/v1" cfg.PrimaryLLM.BaseURL = "https://primary.example/v1"
cfg.PrimaryLLM.TimeoutSeconds = 111 cfg.PrimaryLLM.TimeoutSeconds = 111
cfg.PrimaryLLM.MaxRetries = 2 cfg.PrimaryLLM.MaxRetries = 2
cfg.PrimaryLLM.Concurrency = 7 cfg.TotalLLMConcurrency = 7
cfg.syncLegacyConcurrencyAliases()
effective := cfg.EffectiveValidationLLMConfig() effective := cfg.EffectiveValidationLLMConfig()
if effective.APIKey != "primary-key" || effective.Model != "primary-model" || effective.BaseURL != "https://primary.example/v1" || effective.TimeoutSeconds != 111 || effective.MaxRetries != 2 || effective.Concurrency != 7 { if effective.APIKey != "primary-key" || effective.Model != "primary-model" || effective.BaseURL != "https://primary.example/v1" || effective.TimeoutSeconds != 111 || effective.MaxRetries != 2 || effective.Concurrency != 7 {
@@ -208,7 +305,8 @@ func TestEffectiveValidationLLMInheritance(t *testing.T) {
cfg.ValidationLLM.TimeoutSeconds = &validationTimeout cfg.ValidationLLM.TimeoutSeconds = &validationTimeout
cfg.ValidationLLM.MaxRetries = &validationRetries cfg.ValidationLLM.MaxRetries = &validationRetries
validationConcurrency := 4 validationConcurrency := 4
cfg.ValidationLLM.Concurrency = &validationConcurrency cfg.ValidationLLMConcurrency = &validationConcurrency
cfg.syncLegacyConcurrencyAliases()
effective = cfg.EffectiveValidationLLMConfig() effective = cfg.EffectiveValidationLLMConfig()
if effective.APIKey != "validation-key" || effective.Model != "validation-model" || effective.BaseURL != "https://validation.example/v1" || effective.TimeoutSeconds != 222 || effective.MaxRetries != 9 || effective.Concurrency != 4 { if effective.APIKey != "validation-key" || effective.Model != "validation-model" || effective.BaseURL != "https://validation.example/v1" || effective.TimeoutSeconds != 222 || effective.MaxRetries != 9 || effective.Concurrency != 4 {
@@ -216,34 +314,50 @@ func TestEffectiveValidationLLMInheritance(t *testing.T) {
} }
} }
func TestValidationLLMConcurrencyCannotExceedPrimary(t *testing.T) { func TestValidationLLMConcurrencyCannotExceedTotal(t *testing.T) {
cfg := Default() cfg := Default()
cfg.PrimaryLLM.Concurrency = 2 cfg.TotalLLMConcurrency = 2
cfg.ProposalLLMConcurrency = 2
validationConcurrency := 3 validationConcurrency := 3
cfg.ValidationLLM.Concurrency = &validationConcurrency cfg.ValidationLLMConcurrency = &validationConcurrency
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error when validation llm concurrency exceeds primary") t.Fatal("expected validation error when validation llm concurrency exceeds total")
} }
validationConcurrency = 2 validationConcurrency = 2
cfg.ValidationLLM.Concurrency = &validationConcurrency cfg.ValidationLLMConcurrency = &validationConcurrency
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
t.Fatalf("expected equal concurrency to validate, got %v", err) t.Fatalf("expected equal concurrency to validate, got %v", err)
} }
} }
func TestCLIPrimaryLLMConcurrencyOverrideDrivesEffectiveValidationConcurrencyWhenValidationUnset(t *testing.T) { func TestProposalLLMConcurrencyCannotExceedTotal(t *testing.T) {
cfg := Default() cfg := Default()
llmConcurrency := 6 cfg.TotalLLMConcurrency = 2
if err := cfg.ApplyCLIOverrides(CLIOverrides{PrimaryLLMConcurrency: &llmConcurrency}); err != nil { cfg.ProposalLLMConcurrency = 3
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error when proposal llm concurrency exceeds total")
}
cfg.ProposalLLMConcurrency = 2
if err := cfg.Validate(); err != nil {
t.Fatalf("expected equal concurrency to validate, got %v", err)
}
}
func TestCLITotalLLMConcurrencyOverrideDrivesEffectiveValidationConcurrencyWhenValidationUnset(t *testing.T) {
cfg := Default()
totalLLMConcurrency := 6
if err := cfg.ApplyCLIOverrides(CLIOverrides{TotalLLMConcurrency: &totalLLMConcurrency}); err != nil {
t.Fatalf("ApplyCLIOverrides failed: %v", err) t.Fatalf("ApplyCLIOverrides failed: %v", err)
} }
if cfg.ValidationLLM.Concurrency != nil { if cfg.ValidationLLMConcurrency != nil {
t.Fatalf("expected validation concurrency to remain unset, got %#v", cfg.ValidationLLM.Concurrency) t.Fatalf("expected validation concurrency to remain unset, got %#v", cfg.ValidationLLMConcurrency)
} }
if cfg.EffectiveValidationLLMConfig().Concurrency != 6 { if cfg.EffectiveValidationLLMConcurrency() != 6 {
t.Fatalf("expected inherited validation concurrency 6, got %d", cfg.EffectiveValidationLLMConfig().Concurrency) t.Fatalf("expected inherited validation concurrency 6, got %d", cfg.EffectiveValidationLLMConcurrency())
} }
} }

View File

@@ -68,12 +68,37 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
} }
cfg.PrimaryLLM.MaxRetries = value cfg.PrimaryLLM.MaxRetries = value
} }
totalConcurrencySet := false
if raw, ok := lookup("AUDITA_TOTAL_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_TOTAL_LLM_CONCURRENCY: %w", err)
}
cfg.TotalLLMConcurrency = value
totalConcurrencySet = true
}
if raw, ok := lookup("AUDITA_LLM_CONCURRENCY"); ok { if raw, ok := lookup("AUDITA_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw) value, err := parseInt(raw)
if err != nil { if err != nil {
return Config{}, fmt.Errorf("AUDITA_LLM_CONCURRENCY: %w", err) return Config{}, fmt.Errorf("AUDITA_LLM_CONCURRENCY: %w", err)
} }
cfg.PrimaryLLM.Concurrency = value if !totalConcurrencySet {
cfg.TotalLLMConcurrency = value
totalConcurrencySet = true
}
}
proposalConcurrencySet := false
if raw, ok := lookup("AUDITA_PROPOSAL_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_PROPOSAL_LLM_CONCURRENCY: %w", err)
}
cfg.ProposalLLMConcurrency = value
proposalConcurrencySet = true
}
if totalConcurrencySet && !proposalConcurrencySet {
cfg.ProposalLLMConcurrency = cfg.TotalLLMConcurrency
} }
if raw, ok := lookup("AUDITA_VALIDATION_MAX_RETRIES"); ok { if raw, ok := lookup("AUDITA_VALIDATION_MAX_RETRIES"); ok {
@@ -88,7 +113,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if err != nil { if err != nil {
return Config{}, fmt.Errorf("AUDITA_VALIDATION_LLM_CONCURRENCY: %w", err) return Config{}, fmt.Errorf("AUDITA_VALIDATION_LLM_CONCURRENCY: %w", err)
} }
cfg.ValidationLLM.Concurrency = &value cfg.ValidationLLMConcurrency = &value
} }
if raw, ok := lookup("AUDITA_VALIDATION_MAX_PROMPT_TOKENS"); ok { if raw, ok := lookup("AUDITA_VALIDATION_MAX_PROMPT_TOKENS"); ok {
@@ -188,6 +213,8 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
cfg.WorkDirRetention = WorkDirRetention(raw) cfg.WorkDirRetention = WorkDirRetention(raw)
} }
cfg.syncLegacyConcurrencyAliases()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return Config{}, err return Config{}, err
} }

View File

@@ -11,6 +11,8 @@ type CLIOverrides struct {
PrimaryBaseURL *string PrimaryBaseURL *string
ValidationBaseURL *string ValidationBaseURL *string
PrimaryLLMTimeoutSeconds *int PrimaryLLMTimeoutSeconds *int
TotalLLMConcurrency *int
ProposalLLMConcurrency *int
PrimaryLLMConcurrency *int PrimaryLLMConcurrency *int
ValidationLLMTimeoutSeconds *int ValidationLLMTimeoutSeconds *int
MaxRetries *int MaxRetries *int
@@ -62,8 +64,24 @@ func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error {
if overrides.PrimaryLLMTimeoutSeconds != nil { if overrides.PrimaryLLMTimeoutSeconds != nil {
c.PrimaryLLM.TimeoutSeconds = *overrides.PrimaryLLMTimeoutSeconds c.PrimaryLLM.TimeoutSeconds = *overrides.PrimaryLLMTimeoutSeconds
} }
if overrides.PrimaryLLMConcurrency != nil { totalConcurrencySet := false
c.PrimaryLLM.Concurrency = *overrides.PrimaryLLMConcurrency if overrides.TotalLLMConcurrency != nil {
c.TotalLLMConcurrency = *overrides.TotalLLMConcurrency
totalConcurrencySet = true
}
// Backward-compatible alias: --llm-concurrency maps to total concurrency
// only when --total-llm-concurrency is not set in the same CLI invocation.
if overrides.PrimaryLLMConcurrency != nil && !totalConcurrencySet {
c.TotalLLMConcurrency = *overrides.PrimaryLLMConcurrency
totalConcurrencySet = true
}
proposalConcurrencySet := false
if overrides.ProposalLLMConcurrency != nil {
c.ProposalLLMConcurrency = *overrides.ProposalLLMConcurrency
proposalConcurrencySet = true
}
if totalConcurrencySet && !proposalConcurrencySet {
c.ProposalLLMConcurrency = c.TotalLLMConcurrency
} }
if overrides.ValidationLLMTimeoutSeconds != nil { if overrides.ValidationLLMTimeoutSeconds != nil {
value := *overrides.ValidationLLMTimeoutSeconds value := *overrides.ValidationLLMTimeoutSeconds
@@ -78,7 +96,7 @@ func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error {
} }
if overrides.ValidationLLMConcurrency != nil { if overrides.ValidationLLMConcurrency != nil {
value := *overrides.ValidationLLMConcurrency value := *overrides.ValidationLLMConcurrency
c.ValidationLLM.Concurrency = &value c.ValidationLLMConcurrency = &value
} }
if overrides.ValidationMaxPromptTokens != nil { if overrides.ValidationMaxPromptTokens != nil {
c.ValidationMaxPromptTokens = *overrides.ValidationMaxPromptTokens c.ValidationMaxPromptTokens = *overrides.ValidationMaxPromptTokens
@@ -124,5 +142,7 @@ func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error {
c.WorkDirRetention = WorkDirRetention(*overrides.WorkDirRetention) c.WorkDirRetention = WorkDirRetention(*overrides.WorkDirRetention)
} }
c.syncLegacyConcurrencyAliases()
return c.Validate() return c.Validate()
} }

View File

@@ -24,8 +24,14 @@ func (c Config) Validate() error {
if c.PrimaryLLM.MaxRetries < 0 { if c.PrimaryLLM.MaxRetries < 0 {
issues = append(issues, "max retries must be zero or greater") issues = append(issues, "max retries must be zero or greater")
} }
if c.PrimaryLLM.Concurrency <= 0 { if c.TotalLLMConcurrency <= 0 {
issues = append(issues, "primary llm concurrency must be greater than zero") issues = append(issues, "total llm concurrency must be greater than zero")
}
if c.ProposalLLMConcurrency <= 0 {
issues = append(issues, "proposal llm concurrency must be greater than zero")
}
if c.ProposalLLMConcurrency > c.TotalLLMConcurrency {
issues = append(issues, "proposal llm concurrency must be less than or equal to total llm concurrency")
} }
if c.ValidationLLM.TimeoutSeconds != nil && *c.ValidationLLM.TimeoutSeconds <= 0 { if c.ValidationLLM.TimeoutSeconds != nil && *c.ValidationLLM.TimeoutSeconds <= 0 {
@@ -34,11 +40,11 @@ func (c Config) Validate() error {
if c.ValidationLLM.MaxRetries != nil && *c.ValidationLLM.MaxRetries < 0 { if c.ValidationLLM.MaxRetries != nil && *c.ValidationLLM.MaxRetries < 0 {
issues = append(issues, "validation max retries must be zero or greater") issues = append(issues, "validation max retries must be zero or greater")
} }
if c.ValidationLLM.Concurrency != nil && *c.ValidationLLM.Concurrency <= 0 { if c.ValidationLLMConcurrency != nil && *c.ValidationLLMConcurrency <= 0 {
issues = append(issues, "validation llm concurrency must be greater than zero") issues = append(issues, "validation llm concurrency must be greater than zero")
} }
if c.ValidationLLM.Concurrency != nil && *c.ValidationLLM.Concurrency > c.PrimaryLLM.Concurrency { if c.ValidationLLMConcurrency != nil && *c.ValidationLLMConcurrency > c.TotalLLMConcurrency {
issues = append(issues, "validation llm concurrency must be less than or equal to primary llm concurrency") issues = append(issues, "validation llm concurrency must be less than or equal to total llm concurrency")
} }
if c.ValidationMaxPromptTokens <= 0 { if c.ValidationMaxPromptTokens <= 0 {

View File

@@ -21,13 +21,13 @@ type EffectiveConfig struct {
// ResolvePrimaryConfig resolves the primary LLM settings into runtime form. // ResolvePrimaryConfig resolves the primary LLM settings into runtime form.
func ResolvePrimaryConfig(cfg config.Config) EffectiveConfig { func ResolvePrimaryConfig(cfg config.Config) EffectiveConfig {
return resolveFromLLMConfig(cfg.PrimaryLLM) return resolveFromLLMConfig(cfg.PrimaryLLM, cfg.TotalLLMConcurrency)
} }
// ResolveValidationConfig resolves validation LLM settings with inheritance // ResolveValidationConfig resolves validation LLM settings with inheritance
// from primary values when validation overrides are unset. // from primary values when validation overrides are unset.
func ResolveValidationConfig(cfg config.Config) EffectiveConfig { func ResolveValidationConfig(cfg config.Config) EffectiveConfig {
return resolveFromLLMConfig(cfg.EffectiveValidationLLMConfig()) return resolveFromLLMConfig(cfg.EffectiveValidationLLMConfig(), cfg.EffectiveValidationLLMConcurrency())
} }
// ToInstructorClientConfig converts an effective runtime config into adapter // ToInstructorClientConfig converts an effective runtime config into adapter
@@ -44,13 +44,13 @@ func (c EffectiveConfig) ToInstructorClientConfig(mode Mode, httpClient *http.Cl
} }
} }
func resolveFromLLMConfig(raw config.LLMConfig) EffectiveConfig { func resolveFromLLMConfig(raw config.LLMConfig, concurrency int) EffectiveConfig {
return EffectiveConfig{ return EffectiveConfig{
APIKey: strings.TrimSpace(raw.APIKey), APIKey: strings.TrimSpace(raw.APIKey),
Model: strings.TrimSpace(raw.Model), Model: strings.TrimSpace(raw.Model),
BaseURL: strings.TrimSpace(raw.BaseURL), BaseURL: strings.TrimSpace(raw.BaseURL),
RequestTimeout: time.Duration(raw.TimeoutSeconds) * time.Second, RequestTimeout: time.Duration(raw.TimeoutSeconds) * time.Second,
MaxRetries: raw.MaxRetries, MaxRetries: raw.MaxRetries,
Concurrency: raw.Concurrency, Concurrency: concurrency,
} }
} }

View File

@@ -14,7 +14,8 @@ func TestResolvePrimaryConfig(t *testing.T) {
cfg.PrimaryLLM.BaseURL = " https://example.test/v1 " cfg.PrimaryLLM.BaseURL = " https://example.test/v1 "
cfg.PrimaryLLM.TimeoutSeconds = 42 cfg.PrimaryLLM.TimeoutSeconds = 42
cfg.PrimaryLLM.MaxRetries = 7 cfg.PrimaryLLM.MaxRetries = 7
cfg.PrimaryLLM.Concurrency = 3 cfg.TotalLLMConcurrency = 3
cfg.ProposalLLMConcurrency = 3
effective := ResolvePrimaryConfig(cfg) effective := ResolvePrimaryConfig(cfg)
if effective.APIKey != "primary-key" { if effective.APIKey != "primary-key" {
@@ -44,7 +45,8 @@ func TestResolveValidationConfigInheritsPrimaryWhenUnset(t *testing.T) {
cfg.PrimaryLLM.BaseURL = "https://primary.example/v1" cfg.PrimaryLLM.BaseURL = "https://primary.example/v1"
cfg.PrimaryLLM.TimeoutSeconds = 90 cfg.PrimaryLLM.TimeoutSeconds = 90
cfg.PrimaryLLM.MaxRetries = 4 cfg.PrimaryLLM.MaxRetries = 4
cfg.PrimaryLLM.Concurrency = 2 cfg.TotalLLMConcurrency = 2
cfg.ProposalLLMConcurrency = 2
effective := ResolveValidationConfig(cfg) effective := ResolveValidationConfig(cfg)
if effective.APIKey != "primary-key" || if effective.APIKey != "primary-key" ||
@@ -64,7 +66,8 @@ func TestResolveValidationConfigOverridesPrimaryWhenSet(t *testing.T) {
cfg.PrimaryLLM.BaseURL = "https://primary.example/v1" cfg.PrimaryLLM.BaseURL = "https://primary.example/v1"
cfg.PrimaryLLM.TimeoutSeconds = 90 cfg.PrimaryLLM.TimeoutSeconds = 90
cfg.PrimaryLLM.MaxRetries = 4 cfg.PrimaryLLM.MaxRetries = 4
cfg.PrimaryLLM.Concurrency = 2 cfg.TotalLLMConcurrency = 2
cfg.ProposalLLMConcurrency = 2
timeout := 12 timeout := 12
retries := 9 retries := 9
@@ -74,7 +77,7 @@ func TestResolveValidationConfigOverridesPrimaryWhenSet(t *testing.T) {
cfg.ValidationLLM.BaseURL = "https://validation.example/v1" cfg.ValidationLLM.BaseURL = "https://validation.example/v1"
cfg.ValidationLLM.TimeoutSeconds = &timeout cfg.ValidationLLM.TimeoutSeconds = &timeout
cfg.ValidationLLM.MaxRetries = &retries cfg.ValidationLLM.MaxRetries = &retries
cfg.ValidationLLM.Concurrency = &concurrency cfg.ValidationLLMConcurrency = &concurrency
effective := ResolveValidationConfig(cfg) effective := ResolveValidationConfig(cfg)
if effective.APIKey != "validation-key" || if effective.APIKey != "validation-key" ||

View File

@@ -8,7 +8,16 @@ import (
// Scheduler bounds concurrent backend LLM calls. // Scheduler bounds concurrent backend LLM calls.
type Scheduler struct { type Scheduler struct {
permits chan struct{} maxConcurrency int
mu sync.Mutex
inFlight int
queue []*waiter
}
type waiter struct {
ready chan struct{}
queued bool
granted bool
} }
// NewScheduler creates a scheduler with a fixed concurrency limit. // NewScheduler creates a scheduler with a fixed concurrency limit.
@@ -17,23 +26,83 @@ func NewScheduler(maxConcurrency int) (*Scheduler, error) {
return nil, fmt.Errorf("max concurrency must be greater than zero") return nil, fmt.Errorf("max concurrency must be greater than zero")
} }
return &Scheduler{ return &Scheduler{
permits: make(chan struct{}, maxConcurrency), maxConcurrency: maxConcurrency,
}, nil }, nil
} }
// Acquire blocks until a permit is available or the context is canceled. // Acquire blocks until a permit is available or the context is canceled.
// The returned release function is safe to call multiple times. // The returned release function is safe to call multiple times.
func (s *Scheduler) Acquire(ctx context.Context) (func(), error) { func (s *Scheduler) Acquire(ctx context.Context) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
if s.inFlight < s.maxConcurrency && len(s.queue) == 0 {
s.inFlight++
s.mu.Unlock()
return s.releaseFunc(), nil
}
w := &waiter{
ready: make(chan struct{}),
queued: true,
}
s.queue = append(s.queue, w)
s.mu.Unlock()
select { select {
case s.permits <- struct{}{}: case <-w.ready:
return s.releaseFunc(), nil
case <-ctx.Done():
s.mu.Lock()
if w.queued {
s.removeQueuedWaiterLocked(w)
s.mu.Unlock()
return nil, ctx.Err()
}
if w.granted {
// Permit was granted concurrently with cancellation; release it to
// avoid leaks and unblock queued callers.
s.inFlight--
s.grantQueuedLocked()
}
s.mu.Unlock()
return nil, ctx.Err()
}
}
func (s *Scheduler) releaseFunc() func() {
var once sync.Once var once sync.Once
return func() { return func() {
once.Do(func() { once.Do(func() {
<-s.permits s.mu.Lock()
if s.inFlight > 0 {
s.inFlight--
s.grantQueuedLocked()
}
s.mu.Unlock()
}) })
}, nil }
case <-ctx.Done(): }
return nil, ctx.Err()
func (s *Scheduler) grantQueuedLocked() {
for s.inFlight < s.maxConcurrency && len(s.queue) > 0 {
w := s.queue[0]
s.queue = s.queue[1:]
w.queued = false
w.granted = true
s.inFlight++
close(w.ready)
}
}
func (s *Scheduler) removeQueuedWaiterLocked(target *waiter) {
for i, w := range s.queue {
if w == target {
w.queued = false
s.queue = append(s.queue[:i], s.queue[i+1:]...)
return
}
} }
} }

View File

@@ -3,12 +3,64 @@ package llm
import ( import (
"context" "context"
"errors" "errors"
"reflect"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
) )
func TestSchedulerFIFOOrdering(t *testing.T) {
s, err := NewScheduler(1)
if err != nil {
t.Fatalf("NewScheduler: %v", err)
}
firstRelease, err := s.Acquire(context.Background())
if err != nil {
t.Fatalf("Acquire(first): %v", err)
}
gotOrder := make(chan int, 3)
waitChans := []chan struct{}{
make(chan struct{}),
make(chan struct{}),
make(chan struct{}),
}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
id := i
wg.Add(1)
go func() {
defer wg.Done()
runErr := s.Run(context.Background(), func(context.Context) error {
gotOrder <- id
<-waitChans[id]
return nil
})
if runErr != nil {
t.Errorf("Run[%d] error: %v", id, runErr)
}
}()
time.Sleep(10 * time.Millisecond)
}
firstRelease()
order := make([]int, 0, 3)
for i := 0; i < 3; i++ {
id := <-gotOrder
order = append(order, id)
close(waitChans[id])
}
wg.Wait()
if !reflect.DeepEqual(order, []int{0, 1, 2}) {
t.Fatalf("expected FIFO order [0 1 2], got %v", order)
}
}
func TestSchedulerEnforcesMaxConcurrency(t *testing.T) { func TestSchedulerEnforcesMaxConcurrency(t *testing.T) {
s, err := NewScheduler(2) s, err := NewScheduler(2)
if err != nil { if err != nil {
@@ -79,7 +131,6 @@ func TestSchedulerReleasesPermitOnError(t *testing.T) {
t.Fatalf("expected %v, got %v", expectedErr, err) t.Fatalf("expected %v, got %v", expectedErr, err)
} }
// Must be able to run again after an error, proving permit release.
if err := s.Run(context.Background(), func(ctx context.Context) error { if err := s.Run(context.Background(), func(ctx context.Context) error {
_ = ctx _ = ctx
return nil return nil
@@ -88,7 +139,7 @@ func TestSchedulerReleasesPermitOnError(t *testing.T) {
} }
} }
func TestSchedulerRespectsContextCancellation(t *testing.T) { func TestSchedulerContextCancellationWhileQueued(t *testing.T) {
s, err := NewScheduler(1) s, err := NewScheduler(1)
if err != nil { if err != nil {
t.Fatalf("NewScheduler: %v", err) t.Fatalf("NewScheduler: %v", err)
@@ -111,3 +162,27 @@ func TestSchedulerRespectsContextCancellation(t *testing.T) {
t.Fatalf("expected deadline exceeded, got %v", err) t.Fatalf("expected deadline exceeded, got %v", err)
} }
} }
func TestSchedulerNoPermitLeakAfterQueuedCancellation(t *testing.T) {
s, err := NewScheduler(1)
if err != nil {
t.Fatalf("NewScheduler: %v", err)
}
firstRelease, err := s.Acquire(context.Background())
if err != nil {
t.Fatalf("Acquire(first): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := s.Acquire(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
firstRelease()
if err := s.Run(context.Background(), func(context.Context) error { return nil }); err != nil {
t.Fatalf("expected scheduler to accept new work after cancellation, got %v", err)
}
}

View File

@@ -7,10 +7,14 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
"sync"
"sync/atomic"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/config"
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
"gitea.maximumdirect.net/eric/audita/internal/framework/llm"
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
) )
@@ -47,6 +51,40 @@ func (s *countingScheduler) Run(ctx context.Context, fn func(context.Context) er
return fn(ctx) return fn(ctx)
} }
type sleepingStructuredClient struct {
inFlight int32
maxInFlight int32
}
func (c *sleepingStructuredClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
_ = req
current := atomic.AddInt32(&c.inFlight, 1)
for {
prior := atomic.LoadInt32(&c.maxInFlight)
if current <= prior || atomic.CompareAndSwapInt32(&c.maxInFlight, prior, current) {
break
}
}
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
atomic.AddInt32(&c.inFlight, -1)
return contracts.StructuredCompletionResponse{}, ctx.Err()
}
atomic.AddInt32(&c.inFlight, -1)
target, ok := out.(*StructuredCorrectionSet)
if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output type")
}
*target = StructuredCorrectionSet{
Corrections: []StructuredCorrectionProposal{
{TargetSegmentID: 1, OriginalText: "x", CorrectedText: "y", Confidence: 0.9},
},
}
return contracts.StructuredCompletionResponse{}, nil
}
func defaultRequest(t *testing.T) Request { func defaultRequest(t *testing.T) Request {
t.Helper() t.Helper()
cfg := config.Default() cfg := config.Default()
@@ -265,3 +303,35 @@ func TestGenerateCandidatesClientError(t *testing.T) {
t.Fatalf("expected error diagnostics artifact under %s", req.DiagnosticsDir) t.Fatalf("expected error diagnostics artifact under %s", req.DiagnosticsDir)
} }
} }
func TestGenerateCandidatesRespectsSchedulerConcurrency(t *testing.T) {
scheduler, err := llm.NewScheduler(2)
if err != nil {
t.Fatalf("NewScheduler: %v", err)
}
client := &sleepingStructuredClient{}
baseReq := defaultRequest(t)
baseReq.LLMClient = client
baseReq.Scheduler = scheduler
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
req := baseReq
req.StartIndex = idx
if _, runErr := GenerateCandidates(context.Background(), req); runErr != nil {
t.Errorf("GenerateCandidates[%d] error: %v", idx, runErr)
}
}(i)
}
wg.Wait()
if got := atomic.LoadInt32(&client.maxInFlight); got > 2 {
t.Fatalf("expected scheduler cap <= 2, got %d", got)
}
if got := atomic.LoadInt32(&client.maxInFlight); got < 2 {
t.Fatalf("expected observed concurrency of at least 2, got %d", got)
}
}

View File

@@ -296,8 +296,8 @@ func collectSectionProposals(ctx context.Context, input collectSectionProposalsI
} }
maxWorkers := 1 maxWorkers := 1
if input.Config != nil && input.Config.PrimaryLLM.Concurrency > 1 { if input.Config != nil && input.Config.EffectiveProposalLLMConcurrency() > 1 {
maxWorkers = input.Config.PrimaryLLM.Concurrency maxWorkers = input.Config.EffectiveProposalLLMConcurrency()
} }
if maxWorkers > len(input.Sections) { if maxWorkers > len(input.Sections) {
maxWorkers = len(input.Sections) maxWorkers = len(input.Sections)

View File

@@ -163,11 +163,12 @@ func TestRunnerProposalsExecutePerChunkSection(t *testing.T) {
} }
} }
func TestRunnerProposalSectionConcurrencyBoundedByPrimaryLLMConcurrency(t *testing.T) { func TestRunnerProposalSectionConcurrencyBoundedByProposalLLMConcurrency(t *testing.T) {
cfg := config.Default() cfg := config.Default()
cfg.MaxSectionTokens = 3 cfg.MaxSectionTokens = 3
cfg.MinSectionTokens = 0 cfg.MinSectionTokens = 0
cfg.PrimaryLLM.Concurrency = 2 cfg.TotalLLMConcurrency = 2
cfg.ProposalLLMConcurrency = 2
transcript := &schema.Transcript{Segments: []schema.Segment{ transcript := &schema.Transcript{Segments: []schema.Segment{
{ID: 1, Text: "one two"}, {ID: 1, Text: "one two"},
@@ -215,7 +216,8 @@ func TestRunnerProposalIndexOrderingIsDeterministicAcrossParallelSections(t *tes
cfg := config.Default() cfg := config.Default()
cfg.MaxSectionTokens = 3 cfg.MaxSectionTokens = 3
cfg.MinSectionTokens = 0 cfg.MinSectionTokens = 0
cfg.PrimaryLLM.Concurrency = 3 cfg.TotalLLMConcurrency = 3
cfg.ProposalLLMConcurrency = 3
transcript := &schema.Transcript{Segments: []schema.Segment{ transcript := &schema.Transcript{Segments: []schema.Segment{
{ID: 1, Text: "alpha one"}, {ID: 1, Text: "alpha one"},
@@ -259,6 +261,12 @@ func TestRunnerProposalIndexOrderingIsDeterministicAcrossParallelSections(t *tes
t.Fatalf("expected deterministic applied-change order by section/segment, got %+v", out.ModuleResults[0].AppliedChanges) t.Fatalf("expected deterministic applied-change order by section/segment, got %+v", out.ModuleResults[0].AppliedChanges)
} }
} }
wantFinal := []string{"ALPHA ONE", "BRAVO TWO", "CHARLIE THREE"}
for i, seg := range out.FinalTranscript.Segments {
if seg.Text != wantFinal[i] {
t.Fatalf("expected deterministic final transcript regardless of section completion order; got %+v", out.FinalTranscript.Segments)
}
}
} }
func TestRunnerSkippedRecorded(t *testing.T) { func TestRunnerSkippedRecorded(t *testing.T) {

View File

@@ -4,7 +4,10 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"sync"
"sync/atomic"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/audita/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/config"
@@ -18,6 +21,54 @@ type fakeStructuredLLMClient struct {
calls []StructuredCompletionRequest calls []StructuredCompletionRequest
} }
type sleepingValidationClient struct {
inFlight int32
maxInFlight int32
}
type boundedScheduler struct {
permits chan struct{}
}
func newBoundedScheduler(max int) *boundedScheduler {
return &boundedScheduler{permits: make(chan struct{}, max)}
}
func (s *boundedScheduler) Run(ctx context.Context, fn func(context.Context) error) error {
select {
case s.permits <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
defer func() { <-s.permits }()
return fn(ctx)
}
func (c *sleepingValidationClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error) {
_ = req
current := atomic.AddInt32(&c.inFlight, 1)
for {
prior := atomic.LoadInt32(&c.maxInFlight)
if current <= prior || atomic.CompareAndSwapInt32(&c.maxInFlight, prior, current) {
break
}
}
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
atomic.AddInt32(&c.inFlight, -1)
return StructuredCompletionResponse{}, ctx.Err()
}
atomic.AddInt32(&c.inFlight, -1)
target := out.(*LLMValidationResponse)
*target = LLMValidationResponse{
Validations: []LLMValidationDecision{
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
},
}
return StructuredCompletionResponse{}, nil
}
func (f *fakeStructuredLLMClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error) { func (f *fakeStructuredLLMClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error) {
_ = ctx _ = ctx
f.calls = append(f.calls, req) f.calls = append(f.calls, req)
@@ -191,3 +242,34 @@ func TestLLMBackedValidatorUnknownProposalIndexFails(t *testing.T) {
t.Fatalf("expected unknown index error, got %v", err) t.Fatalf("expected unknown index error, got %v", err)
} }
} }
func TestLLMBackedValidatorRespectsSchedulerConcurrency(t *testing.T) {
scheduler := newBoundedScheduler(2)
client := &sleepingValidationClient{}
v, err := NewLLMBackedValidator("spoken_form_plausibility_review", LLMValidatorTypeSpokenFormPlausibility, "test-model")
if err != nil {
t.Fatalf("new validator error: %v", err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(0, "gestures", "Jesters")})
req.LLMClient = client
req.Scheduler = scheduler
if _, runErr := v.Validate(context.Background(), req); runErr != nil {
t.Errorf("Validate error: %v", runErr)
}
}()
}
wg.Wait()
if got := atomic.LoadInt32(&client.maxInFlight); got > 2 {
t.Fatalf("expected scheduler cap <= 2, got %d", got)
}
if got := atomic.LoadInt32(&client.maxInFlight); got < 2 {
t.Fatalf("expected observed concurrency of at least 2, got %d", got)
}
}