alt/services/worker/cmd/alt-worker/main_test.go
toki bdbfe8228e feat: scheduled market data refresh - parser_map, refresh status model, CLI output, scheduler backfill, socket handlers
- Add parser_map for market data refresh configuration propagation (cli/worker/api)
- Implement refresh status model with SQLite persistence (status_store.go)
- Add scheduler refresh status headless output to CLI operator
- Extend backfill scheduler with status tracking (start/complete/fail)
- Add socket events for scheduler refresh status (start/complete/fail)
- Update proto definitions for refresh status
- Add generated PB files for client
2026-06-22 18:46:45 +09:00

754 lines
22 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.toki-labs.com/toki/alt/services/worker/internal/config"
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
"git.toki-labs.com/toki/alt/services/worker/internal/scheduler"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
)
// TestStoreBackedDepsWiresBacktestSurfaces guards against a regression where the
// worker process serves backtest reads but leaves the start surface (Starter)
// unwired, which would advertise no backtest-start capability and reject every
// start command. The store is built with a nil pool because the wiring path only
// constructs adapters; it never touches the database at startup.
func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) {
runner := jobs.NewRunner()
deps := storeBackedDeps(runner, postgres.NewStore(nil))
if deps.Starter == nil {
t.Error("expected backtest Starter to be wired")
}
if deps.Analysis == nil || deps.Results == nil {
t.Error("expected backtest read stores to be wired")
}
if deps.Instruments == nil || deps.Bars == nil {
t.Error("expected market stores to be wired")
}
if deps.DailyBarImporter == nil {
t.Error("expected daily bar importer to be wired")
}
if deps.Paper == nil {
t.Error("expected paper trading service to be wired")
}
if deps.Live == nil {
t.Error("expected live trading service to be wired (audit query surface must be up even without a broker)")
}
// Wiring registers the import and run-backtest job handlers on the runner, so
// it must hold at least those two handlers after wiring.
if runner.Len() < 2 {
t.Errorf("expected wiring to register job handlers, runner holds %d", runner.Len())
}
}
func TestStartScheduler(t *testing.T) {
statusStore := scheduler.NewRefreshStatusStore()
// 1. Scheduler disabled (no config path)
cfg := config.Config{
SchedulerEnabled: false,
}
runner := jobs.NewRunner()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := startScheduler(ctx, cfg, runner, statusStore)
if err != nil {
t.Fatalf("expected nil error for disabled scheduler, got %v", err)
}
// 2. Scheduler enabled with invalid config path (should fail)
cfg.SchedulerEnabled = true
cfg.ScheduleConfigPath = "non_existent_file.yaml"
err = startScheduler(ctx, cfg, runner, statusStore)
if err == nil {
t.Fatal("expected error for non existent schedule config, got nil")
}
// 3. Scheduler enabled with valid config
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "schedule.yaml")
if err := os.WriteFile(tmpFile, []byte(`
schedules:
- name: kr-core-daily
provider: kis
selector:
kind: watchlist
market: KR
venue: KRX
name: kr-core
symbols: ["005930"]
timeframe: daily
cadence: daily
timezone: Asia/Seoul
backfill_window: 3d
parallelism_limit: 2
`), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg.ScheduleConfigPath = tmpFile
err = startScheduler(ctx, cfg, runner, statusStore)
if err != nil {
t.Fatalf("expected no error for valid config, got %v", err)
}
}
type mockExecutor struct {
mu sync.Mutex
dispatched int
}
func (m *mockExecutor) Execute(ctx context.Context, job jobs.Job) error {
m.mu.Lock()
m.dispatched++
m.mu.Unlock()
return nil
}
func (m *mockExecutor) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
return jobs.Result{}, false, nil
}
// mockExecutorSuccess always succeeds but returns NO import evidence.
type mockExecutorSuccess struct{}
func (m *mockExecutorSuccess) Execute(ctx context.Context, job jobs.Job) error {
return nil
}
func (m *mockExecutorSuccess) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
// No evidence: success but no import bars.
return jobs.Result{}, false, nil
}
// mockExecutorWithEvidence always succeeds and returns actual import evidence.
type mockExecutorWithEvidence struct {
bars int
instruments int
}
func (m *mockExecutorWithEvidence) Execute(ctx context.Context, job jobs.Job) error {
return nil
}
func (m *mockExecutorWithEvidence) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
return jobs.Result{
Bars: m.bars,
Instruments: m.instruments,
}, true, nil
}
func TestSchedulerLoopDispatchesTickAndStops(t *testing.T) {
schedCfg := scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-core-daily-mock",
Provider: "kis",
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
Timeframe: "daily",
Cadence: "daily",
Timezone: "Asia/Seoul",
BackfillWindow: "3d",
ParallelismLimit: 2,
},
},
}
exec := &mockExecutor{}
opts := scheduler.TickOptions{
Capabilities: scheduler.CapabilityMap(kis.Capability()),
Executor: exec,
}
ctx, cancel := context.WithCancel(context.Background())
ticks := make(chan time.Time)
loopDone := make(chan struct{})
statusStore := scheduler.NewRefreshStatusStore()
go func() {
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
close(loopDone)
}()
// Send 1 tick
ticks <- time.Now()
// Wait briefly for dispatch
time.Sleep(10 * time.Millisecond)
exec.mu.Lock()
dispatchedVal := exec.dispatched
exec.mu.Unlock()
if dispatchedVal == 0 {
t.Error("expected at least 1 job dispatch on tick")
}
// Cancel context to shutdown
cancel()
select {
case <-loopDone:
// success: loop stopped
case <-time.After(1 * time.Second):
t.Error("scheduler loop did not stop on context cancellation")
}
}
// TestSchedulerLoopRecordsTickAndStatusStore verifies that a no-evidence success
// is classified as "stale" (not "success") and does NOT update last_success.
func TestSchedulerLoopRecordsTickAndStatusStore(t *testing.T) {
schedCfg := scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-core-daily-mock",
Provider: "kis",
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
Timeframe: "daily",
Cadence: "daily",
Timezone: "Asia/Seoul",
BackfillWindow: "3d",
ParallelismLimit: 2,
},
},
}
// mockExecutorSuccess always succeeds but returns NO import evidence.
execSucceed := &mockExecutorSuccess{}
opts := scheduler.TickOptions{
Capabilities: scheduler.CapabilityMap(kis.Capability()),
Executor: execSucceed,
}
ctx, cancel := context.WithCancel(context.Background())
ticks := make(chan time.Time)
loopDone := make(chan struct{})
statusStore := scheduler.NewRefreshStatusStore()
go func() {
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
close(loopDone)
}()
// Send a tick and wait for processing.
now := time.Now()
ticks <- now
time.Sleep(50 * time.Millisecond)
// Verify that the status store recorded a "stale" entry (no evidence).
entry, ok := statusStore.Get("kr-core-daily-mock")
if !ok {
t.Fatal("expected status store entry for schedule after tick")
}
if entry.Status != "stale" {
t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale")
}
// CRITICAL: without ImportSummary from executor, ImportedBarCount MUST be 0.
if entry.ImportedBarCount != 0 {
t.Errorf("imported_bar_count = %d, want 0 (no evidence from mock executor)", entry.ImportedBarCount)
}
// last_success must NOT be set for no-evidence.
if !entry.LastSuccess.IsZero() {
t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess)
}
cancel()
select {
case <-loopDone:
case <-time.After(1 * time.Second):
t.Error("scheduler loop did not stop on context cancellation")
}
}
// TestSchedulerLoopWithActualEvidence proves that when the executor returns
// actual RefreshEvidence, the status store correctly propagates imported bar counts.
func TestSchedulerLoopWithActualEvidence(t *testing.T) {
schedCfg := scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-core-daily-evidence",
Provider: "kis",
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
Timeframe: "daily",
Cadence: "daily",
Timezone: "Asia/Seoul",
BackfillWindow: "3d",
ParallelismLimit: 2,
},
},
}
// mockExecutorWithEvidence returns actual import evidence.
execWithEvid := &mockExecutorWithEvidence{bars: 120, instruments: 1}
opts := scheduler.TickOptions{
Capabilities: scheduler.CapabilityMap(kis.Capability()),
Executor: execWithEvid,
}
ctx, cancel := context.WithCancel(context.Background())
ticks := make(chan time.Time)
loopDone := make(chan struct{})
statusStore := scheduler.NewRefreshStatusStore()
go func() {
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
close(loopDone)
}()
now := time.Now()
ticks <- now
time.Sleep(50 * time.Millisecond)
// Verify actual evidence propagation.
entry, ok := statusStore.Get("kr-core-daily-evidence")
if !ok {
t.Fatal("expected status store entry after tick with evidence")
}
if entry.Status != "success" {
t.Errorf("status = %q, want %q", entry.Status, "success")
}
if entry.ImportedBarCount != 120 {
t.Errorf("imported_bar_count = %d, want 120", entry.ImportedBarCount)
}
if entry.LastSuccess.IsZero() {
t.Error("last_success should be set when evidence is present")
}
cancel()
select {
case <-loopDone:
case <-time.After(1 * time.Second):
t.Error("scheduler loop did not stop on context cancellation")
}
}
func TestRecordTickSkippedItemsNotSuccess(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// All items are skipped (e.g., duplicate window running).
items := []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Readiness: "stale", Freshness: "stale", Error: "duplicate window running"},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry after recording skipped items")
}
if entry.Status != "stale" {
t.Errorf("status = %q, want %q", entry.Status, "stale")
}
}
func TestRecordTickFailedItemsError(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
items := []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "failed", Provider: "kis", Timeframe: "daily", Readiness: "error", Freshness: "stale", Error: "connection timeout"},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry after recording failed items")
}
if entry.Status != "error" {
t.Errorf("status = %q, want %q", entry.Status, "error")
}
if entry.LastError != "connection timeout" {
t.Errorf("last_error = %q, want %q", entry.LastError, "connection timeout")
}
}
func TestRecordTickMixedSucceededSkippedStale(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// Some succeeded, some skipped: should be "stale" not "success".
items := []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily"},
{Schedule: "test", Symbol: "000660", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate window running"},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry after recording mixed items")
}
if entry.Status != "stale" {
t.Errorf("status = %q, want %q", entry.Status, "stale")
}
// imported_bar_count should be 0 for stale.
if entry.ImportedBarCount != 0 {
t.Errorf("imported_bar_count = %d, want 0 for stale", entry.ImportedBarCount)
}
}
func TestRecordTickPreservesLastSuccessOnStale(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// First: evidence-backed success.
store.RecordTick("test", []scheduler.ItemResult{
{
Schedule: "test",
Symbol: "005930",
Status: "succeeded",
Provider: "kis",
Timeframe: "daily",
ImportSummary: &scheduler.RefreshEvidence{
HasBars: true,
ImportedBars: 100,
},
},
}, now)
prev, _ := store.Get("test")
if prev.Status != "success" {
t.Fatalf("first tick status = %q, want %q", prev.Status, "success")
}
if prev.LastSuccess.IsZero() {
t.Fatal("last_success should be set after evidence-backed tick")
}
// Second: all skipped (stale). Should preserve last_success.
store.RecordTick("test", []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate"},
}, now.Add(1*time.Minute))
stale, _ := store.Get("test")
if stale.Status != "stale" {
t.Errorf("stale status = %q, want %q", stale.Status, "stale")
}
if !stale.LastSuccess.Equal(prev.LastSuccess) {
t.Errorf("last_success was updated on stale tick: was %v, still %v", prev.LastSuccess, stale.LastSuccess)
}
}
func TestRecordTickUnknownStatusNoSuccessEvidence(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// Unknown status should not create success evidence.
store.RecordTick("test", []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "unknown", Provider: "kis", Timeframe: "daily"},
}, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry after recording unknown status")
}
if entry.Status == "success" {
t.Errorf("status = %q for unknown item, should NOT be %q", entry.Status, "success")
}
}
func TestRecordTickZeroItemsSkipped(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// Zero items: should not create any entry.
store.RecordTick("test", []scheduler.ItemResult{}, now)
_, ok := store.Get("test")
if ok {
t.Error("expected no entry when zero items recorded")
}
}
func TestSchedulerLoopConfigWiring(t *testing.T) {
// Verify that startScheduler saves the schedule config to statusStore
// so that next_run calculations work correctly.
statusStore := scheduler.NewRefreshStatusStore()
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "schedule.yaml")
if err := os.WriteFile(tmpFile, []byte(`
schedules:
- name: kr-core-daily-wire
provider: kis
selector:
kind: watchlist
market: KR
venue: KRX
name: kr-core
symbols: ["005930"]
timeframe: daily
cadence: daily
timezone: Asia/Seoul
backfill_window: 3d
parallelism_limit: 2
`), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg := config.Config{
SchedulerEnabled: true,
ScheduleConfigPath: tmpFile,
}
runner := jobs.NewRunner()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := startScheduler(ctx, cfg, runner, statusStore)
if err != nil {
t.Fatalf("startScheduler: %v", err)
}
// Verify that the schedule config was stored.
storedCfg := statusStore.ScheduleConfig()
if len(storedCfg.Schedules) != 1 {
t.Errorf("stored config has %d schedules, want 1", len(storedCfg.Schedules))
}
if len(storedCfg.Schedules) > 0 && storedCfg.Schedules[0].Name != "kr-core-daily-wire" {
t.Errorf("schedule name = %q, want %q", storedCfg.Schedules[0].Name, "kr-core-daily-wire")
}
}
// TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess proves that
// a no-evidence tick does NOT overwrite last_success from a previous evidence-backed
// success. After this change, no-evidence succeeds are classified as "stale" (not
// "success"), so the previous evidence-backed "success" state is preserved.
func TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess(t *testing.T) {
schedCfg := scheduler.Config{
Schedules: []scheduler.ScheduleConfig{
{
Name: "kr-core-daily-mixed",
Provider: "kis",
Selector: scheduler.SelectorConfig{
Kind: "watchlist",
Market: "KR",
Venue: "KRX",
Name: "kr-core",
Symbols: []string{"005930"},
},
Timeframe: "daily",
Cadence: "daily",
Timezone: "Asia/Seoul",
BackfillWindow: "3d",
ParallelismLimit: 2,
},
},
}
execWithEvid := &mockExecutorWithEvidence{bars: 100, instruments: 1}
opts := scheduler.TickOptions{
Capabilities: scheduler.CapabilityMap(kis.Capability()),
Executor: execWithEvid,
}
ctx, cancel := context.WithCancel(context.Background())
ticks := make(chan time.Time)
loopDone := make(chan struct{})
statusStore := scheduler.NewRefreshStatusStore()
go func() {
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
close(loopDone)
}()
now := time.Now()
ticks <- now
time.Sleep(50 * time.Millisecond)
// Verify evidence-backed success.
entry1, ok := statusStore.Get("kr-core-daily-mixed")
if !ok {
t.Fatal("expected entry after evidence-backed tick")
}
if entry1.Status != "success" {
t.Errorf("status = %q, want %q", entry1.Status, "success")
}
if entry1.ImportedBarCount != 100 {
t.Errorf("imported_bar_count = %d, want 100", entry1.ImportedBarCount)
}
if entry1.LastSuccess.IsZero() {
t.Fatal("last_success should be set after evidence-backed tick")
}
lastSuccessAfterEvidence := entry1.LastSuccess
cancel()
select {
case <-loopDone:
case <-time.After(1 * time.Second):
t.Error("scheduler loop did not stop on context cancellation")
}
// Now test no-evidence preservation via direct RecordTick calls.
// This validates that calling RecordTick with no-evidence (ImportSummary == nil)
// does NOT overwrite the previous evidence-backed "success" state.
entry2, _ := statusStore.Get("kr-core-daily-mixed")
if entry2.Status != "success" {
t.Errorf("pre-test status = %q, want %q", entry2.Status, "success")
}
// RecordTick with no-evidence success item.
statusStore.RecordTick("kr-core-daily-mixed", []scheduler.ItemResult{
{Schedule: "kr-core-daily-mixed", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"},
}, now.Add(1*time.Minute))
entry3, ok := statusStore.Get("kr-core-daily-mixed")
if !ok {
t.Fatal("expected entry after RecordTick")
}
// No-evidence tick replaces previous "success" → the entry becomes "stale"
// because all items have no evidence. The previous last_success must NOT
// be overwritten, but the status itself is now "stale".
if entry3.Status != "stale" {
t.Errorf("post-no-evidence status = %q, want %q (no evidence → stale)", entry3.Status, "stale")
}
// last_success must NOT be updated by no-evidence tick.
if !entry3.LastSuccess.Equal(lastSuccessAfterEvidence) {
t.Errorf("last_success = %v, want %v (preserved from evidence tick)", entry3.LastSuccess, lastSuccessAfterEvidence)
}
// imported_bar_count stays the same.
if entry3.ImportedBarCount != 100 {
t.Errorf("imported_bar_count = %d, want 100 (no evidence to add)", entry3.ImportedBarCount)
}
}
// TestDirectRecordTickNoEvidenceSuccessNoLastSuccess verifies that no-evidence
// success is classified as "stale" and does NOT set last_success.
func TestDirectRecordTickNoEvidenceSuccessNoLastSuccess(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// Direct RecordTick with no-evidence success (ImportSummary == nil).
items := []scheduler.ItemResult{
{Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry")
}
if entry.Status != "stale" {
t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale")
}
// No evidence: status is "stale", so last_success must NOT be set.
if !entry.LastSuccess.IsZero() {
t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess)
}
// No evidence: ImportedBarCount should be 0.
if entry.ImportedBarCount != 0 {
t.Errorf("imported_bar_count = %d, want 0", entry.ImportedBarCount)
}
}
// TestDirectRecordTickEvidenceSuccess sets last_success.
func TestDirectRecordTickEvidenceSuccessSetsLastSuccess(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
items := []scheduler.ItemResult{
{
Schedule: "test",
Symbol: "005930",
Status: "succeeded",
Provider: "kis",
Timeframe: "daily",
Readiness: "ready",
Freshness: "fresh",
ImportSummary: &scheduler.RefreshEvidence{
HasBars: true,
ImportedBars: 150,
},
},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry")
}
if entry.Status != "success" {
t.Errorf("status = %q, want %q", entry.Status, "success")
}
if entry.ImportedBarCount != 150 {
t.Errorf("imported_bar_count = %d, want 150", entry.ImportedBarCount)
}
if entry.LastSuccess.IsZero() {
t.Error("last_success should be set when evidence is present")
}
}
// TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess proves that a mixed
// tick (some succeeded with evidence, some succeeded without) does NOT silently
// upgrade to full success evidence. Only items with evidence contribute bar
// counts; the status is still "success" because all items succeeded, but
// last_success is only updated when hasEvidence is true.
func TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess(t *testing.T) {
store := scheduler.NewRefreshStatusStore()
now := time.Now()
// Two items: one with evidence, one without.
items := []scheduler.ItemResult{
{
Schedule: "test",
Symbol: "005930",
Status: "succeeded",
Provider: "kis",
Timeframe: "daily",
ImportSummary: &scheduler.RefreshEvidence{
HasBars: true,
ImportedBars: 100,
},
},
{
Schedule: "test",
Symbol: "000660",
Status: "succeeded",
Provider: "kis",
Timeframe: "daily",
// No ImportSummary — no evidence.
},
}
store.RecordTick("test", items, now)
entry, ok := store.Get("test")
if !ok {
t.Fatal("expected entry")
}
// All items succeeded → status is "success" even with mixed evidence.
if entry.Status != "success" {
t.Errorf("status = %q, want %q", entry.Status, "success")
}
// Only the item with evidence contributes bar counts.
if entry.ImportedBarCount != 100 {
t.Errorf("imported_bar_count = %d, want 100", entry.ImportedBarCount)
}
// hasEvidence is true (at least one item has evidence) → last_success is set.
if entry.LastSuccess.IsZero() {
t.Error("last_success should be set when hasEvidence is true (mixed with at least one evidence)")
}
}