@@ -38,6 +38,9 @@ AtlasSession <- R6::R6Class("AtlasSession",
3838 dir = NULL ,
3939 # ' @field code Character vector of every code chunk executed so far.
4040 code = character (),
41+ # ' @field test_data Held-out test rows (when `test_prop > 0`); never
42+ # ' placed in the agent's environment.
43+ test_data = NULL ,
4144
4245 # ' @description Create a session.
4346 # ' @param data A data.frame.
@@ -79,36 +82,54 @@ AtlasSession <- R6::R6Class("AtlasSession",
7982 # ' return `NULL` when there is nothing to say. Used by [atlas_app()]'s
8083 # ' "Send now" box; front-ends typically read the message from a file or
8184 # ' queue.
85+ # ' @param autonomous Run without a human: the agent states its plan and
86+ # ' proceeds instead of asking for approval, and never calls `ask_user`.
87+ # ' @param test_prop Proportion of rows (0 to <1) to hold out as a final
88+ # ' test set **the agent never sees**. Final models are evaluated on it
89+ # ' by Atlas itself after the run (`$results()$test_leaderboard`), so
90+ # ' the comparison can't be gamed by overfitting the agent's own
91+ # ' validation scheme. `0` (default) disables the split.
8292 initialize = function (data , outcome , n_models = 3 , goal = NULL ,
8393 constraints = NULL , chat = NULL , dir = NULL ,
8494 on_ask = NULL , display = c(" console" , " markdown" ),
8595 patience = 3 , min_improve = 0.05 , exclude = NULL ,
86- interject = NULL ) {
96+ interject = NULL , autonomous = FALSE ,
97+ test_prop = 0 ) {
8798 private $ on_ask <- on_ask
8899 private $ interject <- interject
89100 private $ display <- match.arg(display )
90101 stopifnot(is.data.frame(data ), is.character(outcome ), length(outcome ) == 1 ,
91102 is.numeric(n_models ), length(n_models ) == 1 , n_models > = 1 ,
92103 is.numeric(patience ), patience > = 1 ,
93- is.numeric(min_improve ), min_improve > = 0 , min_improve < = 1 )
104+ is.numeric(min_improve ), min_improve > = 0 , min_improve < = 1 ,
105+ is.numeric(test_prop ), test_prop > = 0 , test_prop < 1 )
94106 if (! outcome %in% names(data )) {
95107 stop(" outcome '" , outcome , " ' is not a column of `data`" , call. = FALSE )
96108 }
97109 if (outcome %in% exclude ) {
98110 stop(" the outcome cannot be excluded" , call. = FALSE )
99111 }
100112 data <- data [setdiff(names(data ), exclude )]
113+ if (test_prop > 0 ) {
114+ idx <- seeded_sample(nrow(data ), max(1 , round(nrow(data ) * test_prop )))
115+ self $ test_data <- data [idx , , drop = FALSE ]
116+ data <- data [- idx , , drop = FALSE ]
117+ }
101118 leakage <- tryCatch(atlas_leakage_screen(data , outcome ),
102119 error = function (e ) NULL )
103120 private $ meta <- list (outcome = outcome , n_models = n_models , goal = goal ,
104121 constraints = normalize_constraints(constraints ),
105122 patience = patience , min_improve = min_improve ,
106- exclude = exclude , leakage = leakage )
123+ exclude = exclude , leakage = leakage ,
124+ autonomous = autonomous , test_prop = test_prop )
107125 self $ dir <- path.expand(
108126 dir %|| % file.path(getOption(" atlas.dir" , " .atlas" ),
109127 format(Sys.time(), " %Y%m%d-%H%M%S" )))
110128 dir.create(self $ dir , recursive = TRUE , showWarnings = FALSE )
111129 saveRDS(data , file.path(self $ dir , " data.rds" ))
130+ if (! is.null(self $ test_data )) {
131+ saveRDS(self $ test_data , file.path(self $ dir , " test.rds" ))
132+ }
112133 saveRDS(private $ meta , file.path(self $ dir , " meta.rds" ))
113134
114135 self $ env <- new.env(parent = globalenv())
@@ -117,7 +138,7 @@ AtlasSession <- R6::R6Class("AtlasSession",
117138 sys_prompt <- atlas_system_prompt(
118139 n_models , length(private $ meta $ constraints ) > 0 ,
119140 patience = patience , min_improve = min_improve ,
120- has_modelblueprint = mb_available())
141+ has_modelblueprint = mb_available(), autonomous = autonomous )
121142 if (identical(private $ display , " markdown" )) {
122143 sys_prompt <- paste0(
123144 sys_prompt , " \n\n Your narration and code output are rendered as" ,
@@ -221,9 +242,17 @@ AtlasSession <- R6::R6Class("AtlasSession",
221242 warning(" agent has not produced `atlas_models` yet; " ,
222243 " run $build() or inspect $chat" , call. = FALSE )
223244 }
245+ test_lb <- NULL
246+ if (! is.null(self $ test_data ) && ! is.null(self $ env $ atlas_models )) {
247+ test_lb <- evaluate_on_test(self $ env $ atlas_models , self $ test_data ,
248+ private $ meta $ outcome )
249+ utils :: write.csv(test_lb , file.path(self $ dir , " test_leaderboard.csv" ),
250+ row.names = FALSE )
251+ }
224252 structure(
225253 list (models = self $ env $ atlas_models ,
226254 leaderboard = self $ env $ atlas_leaderboard ,
255+ test_leaderboard = test_lb ,
227256 constraints = self $ check(),
228257 report = private $ last_report , code = self $ code ,
229258 dir = self $ dir , session = self ),
@@ -460,7 +489,8 @@ atlas_resume <- function(dir, chat = NULL, ...) {
460489 goal = meta $ goal , constraints = meta $ constraints ,
461490 chat = chat , dir = dir ,
462491 patience = meta $ patience %|| % 3 ,
463- min_improve = meta $ min_improve %|| % 0.05 , ... )
492+ min_improve = meta $ min_improve %|| % 0.05 ,
493+ autonomous = meta $ autonomous %|| % FALSE , ... )
464494 code_path <- file.path(dir , " code.rds" )
465495 if (file.exists(code_path )) {
466496 s $ code <- readRDS(code_path )
@@ -470,9 +500,59 @@ atlas_resume <- function(dir, chat = NULL, ...) {
470500 }
471501 turns_path <- file.path(dir , " turns.rds" )
472502 if (file.exists(turns_path )) s $ chat $ set_turns(readRDS(turns_path ))
503+ test_path <- file.path(dir , " test.rds" )
504+ if (file.exists(test_path )) s $ test_data <- readRDS(test_path )
473505 s
474506}
475507
508+ # Deterministic sample that leaves the caller's RNG stream untouched.
509+ seeded_sample <- function (n , size , seed = 1 ) {
510+ old <- if (exists(" .Random.seed" , globalenv(), inherits = FALSE )) {
511+ get(" .Random.seed" , globalenv())
512+ }
513+ on.exit(if (! is.null(old )) assign(" .Random.seed" , old , globalenv()),
514+ add = TRUE )
515+ set.seed(seed )
516+ sample.int(n , size )
517+ }
518+
519+ # Pick a sensible test metric from the outcome: RMSE for continuous
520+ # outcomes, accuracy otherwise (thresholding numeric predictions at 0.5
521+ # for binary targets).
522+ auto_metric <- function (y ) {
523+ if (is.numeric(y ) && length(unique(y )) > 5 ) {
524+ list (name = " rmse" , higher_better = FALSE ,
525+ fn = function (actual , predicted ) {
526+ sqrt(mean((actual - as.numeric(predicted ))^ 2 ))
527+ })
528+ } else {
529+ list (name = " accuracy" , higher_better = TRUE ,
530+ fn = function (actual , predicted ) {
531+ if (is.numeric(predicted ) && is.numeric(actual ) &&
532+ all(actual %in% c(0 , 1 ))) {
533+ predicted <- as.numeric(predicted > = 0.5 )
534+ }
535+ mean(as.character(actual ) == as.character(predicted ))
536+ })
537+ }
538+ }
539+
540+ # Atlas-side final evaluation: the agent never touches the test rows, so
541+ # this ranking can't be gamed. Models that can't predict get NA, not an
542+ # error - one broken candidate shouldn't sink the run.
543+ evaluate_on_test <- function (models , test , outcome ) {
544+ m <- auto_metric(test [[outcome ]])
545+ rows <- lapply(names(models ), function (nm ) {
546+ value <- tryCatch(
547+ m $ fn(test [[outcome ]], stats :: predict(models [[nm ]], newdata = test )),
548+ error = function (e ) NA_real_ )
549+ data.frame (model = nm , metric = m $ name , value = value )
550+ })
551+ out <- do.call(rbind , rows )
552+ out [order(out $ value , decreasing = m $ higher_better , na.last = TRUE ), ,
553+ drop = FALSE ]
554+ }
555+
476556# modelblueprint is optional: only mention it to the agent (and only allow
477557# its file writes) when it is actually installed
478558mb_available <- function () {
@@ -481,7 +561,8 @@ mb_available <- function() {
481561
482562atlas_system_prompt <- function (n_models , has_constraints = FALSE ,
483563 patience = 3 , min_improve = 0.05 ,
484- has_modelblueprint = TRUE ) {
564+ has_modelblueprint = TRUE ,
565+ autonomous = FALSE ) {
485566 paste(
486567 " You are Atlas, an expert R statistician and ML engineer. You build models" ,
487568 " by writing R code and running it with the run_r_code tool. You can ask" ,
@@ -514,17 +595,28 @@ atlas_system_prompt <- function(n_models, has_constraints = FALSE,
514595 " CV, fixed seed). Also consider which predictors should have a" ,
515596 " monotone effect on the outcome as a matter of domain sense (e.g. a" ,
516597 " house's price should not fall as floor area grows); include any such" ,
517- " suggestions, with direction and a one-line why, in the plan. Present" ,
518- " the plan with ask_user, explicitly inviting approval, changes, or" ,
519- " extra instructions. Incorporate whatever the user says - if they ask" ,
520- " for substantial changes, restate the revised plan in one short" ,
521- " paragraph before proceeding - and call add_monotone_constraint for" ,
522- " each monotone suggestion the user approves. Never start fitting" ,
523- " until the user has responded." ,
524- " 3. The user may also steer you mid-build (through ask_user answers or" ,
525- " messages in tool results): treat instructions like 'focus on" ,
526- " improvements' or 'change the feature engineering' as immediate" ,
527- " course corrections, acknowledge them, and adjust the plan." ,
598+ if (autonomous ) paste0(
599+ " suggestions, with direction and a one-line why, in the plan. You" ,
600+ " \n are running autonomously: state the plan, apply monotone" ,
601+ " \n constraints that are clearly right on domain grounds via" ,
602+ " \n add_monotone_constraint, and proceed without waiting." )
603+ else paste0(
604+ " suggestions, with direction and a one-line why, in the plan. Present" ,
605+ " \n the plan with ask_user, explicitly inviting approval, changes, or" ,
606+ " \n extra instructions. Incorporate whatever the user says - if they ask" ,
607+ " \n for substantial changes, restate the revised plan in one short" ,
608+ " \n paragraph before proceeding - and call add_monotone_constraint for" ,
609+ " \n each monotone suggestion the user approves. Never start fitting" ,
610+ " \n until the user has responded." ),
611+ if (autonomous ) paste0(
612+ " 3. No user is available at any point in this run. Never call" ,
613+ " \n ask_user; make reasonable decisions yourself and record them in" ,
614+ " \n your narration." )
615+ else paste0(
616+ " 3. The user may also steer you mid-build (through ask_user answers or" ,
617+ " \n messages in tool results): treat instructions like 'focus on" ,
618+ " \n improvements' or 'change the feature engineering' as immediate" ,
619+ " \n course corrections, acknowledge them, and adjust the plan." ),
528620 " 4. Fit and evaluate each candidate on held-out data. After each one," ,
529621 " narrate one line: model name, metric, value." ,
530622 " 5. Refit each candidate on all rows for the final versions." ,
@@ -547,7 +639,10 @@ atlas_system_prompt <- function(n_models, has_constraints = FALSE,
547639 " - Prefer base R; check optional packages with requireNamespace() and fall" ,
548640 " back gracefully if missing." ,
549641 " - Keep each code chunk small; inspect output before continuing." ,
550- " - Use ask_user when a decision genuinely needs the user; otherwise proceed." ,
642+ if (autonomous )
643+ " - Never call ask_user: no user is available for this run."
644+ else
645+ " - Use ask_user when a decision genuinely needs the user; otherwise proceed." ,
551646 " - Constraints can be added mid-session with add_monotone_constraint." ,
552647 " Machine checks require `predict(model, newdata)` to work on a" ,
553648 " data.frame like `data`; make sure every model in `atlas_models`" ,
@@ -619,12 +714,18 @@ atlas_validation_prompt <- function(dir) {
619714
620715atlas_task_prompt <- function (data , meta ) {
621716 profile <- utils :: capture.output(utils :: str(data , list.len = 50 ))
622- paste(
717+ # unlist() drops the NULLs from inactive sections, so no blank gaps
718+ parts <- list (
623719 sprintf(" Build up to %d models predicting `%s` from the other columns." ,
624720 meta $ n_models , meta $ outcome ),
625721 sprintf(" The data.frame `data` has %d rows and %d columns:" ,
626722 nrow(data ), ncol(data )),
627723 paste(profile , collapse = " \n " ),
724+ if (isTRUE(meta $ test_prop > 0 )) paste0(
725+ " A further " , round(meta $ test_prop * 100 ), " % of rows has been held " ,
726+ " out as a final test set that you will NEVER see. Every final model " ,
727+ " is evaluated on it after the run, so optimise for genuine " ,
728+ " generalisation, not for your own validation score." ),
628729 if (length(meta $ exclude ) > 0 ) paste0(
629730 " The user excluded these columns (unavailable at prediction time, or " ,
630731 " leakage risk); they have already been removed from `data`: " ,
@@ -648,9 +749,9 @@ atlas_task_prompt <- function(data, meta) {
648749 function (ci ) is.null(ci $ check ), logical (1 )),
649750 " " , " (machine-checked)" )),
650751 collapse = " \n " )),
651- if (! is.null(meta $ goal )) paste(" Additional instructions:" , meta $ goal ),
652- sep = " \n\n "
752+ if (! is.null(meta $ goal )) paste(" Additional instructions:" , meta $ goal )
653753 )
754+ paste(unlist(parts ), collapse = " \n\n " )
654755}
655756
656757atlas_fix_prompt <- function (fails ) {
0 commit comments