edsl 0.1.39.dev3__py3-none-any.whl → 0.1.39.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. edsl/Base.py +413 -332
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +57 -49
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +1071 -867
  7. edsl/agents/AgentList.py +551 -413
  8. edsl/agents/Invigilator.py +284 -233
  9. edsl/agents/InvigilatorBase.py +257 -270
  10. edsl/agents/PromptConstructor.py +272 -354
  11. edsl/agents/QuestionInstructionPromptBuilder.py +128 -0
  12. edsl/agents/QuestionTemplateReplacementsBuilder.py +137 -0
  13. edsl/agents/__init__.py +2 -3
  14. edsl/agents/descriptors.py +99 -99
  15. edsl/agents/prompt_helpers.py +129 -129
  16. edsl/agents/question_option_processor.py +172 -0
  17. edsl/auto/AutoStudy.py +130 -117
  18. edsl/auto/StageBase.py +243 -230
  19. edsl/auto/StageGenerateSurvey.py +178 -178
  20. edsl/auto/StageLabelQuestions.py +125 -125
  21. edsl/auto/StagePersona.py +61 -61
  22. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  23. edsl/auto/StagePersonaDimensionValues.py +74 -74
  24. edsl/auto/StagePersonaDimensions.py +69 -69
  25. edsl/auto/StageQuestions.py +74 -73
  26. edsl/auto/SurveyCreatorPipeline.py +21 -21
  27. edsl/auto/utilities.py +218 -224
  28. edsl/base/Base.py +279 -279
  29. edsl/config.py +177 -157
  30. edsl/conversation/Conversation.py +290 -290
  31. edsl/conversation/car_buying.py +59 -58
  32. edsl/conversation/chips.py +95 -95
  33. edsl/conversation/mug_negotiation.py +81 -81
  34. edsl/conversation/next_speaker_utilities.py +93 -93
  35. edsl/coop/CoopFunctionsMixin.py +15 -0
  36. edsl/coop/ExpectedParrotKeyHandler.py +125 -0
  37. edsl/coop/PriceFetcher.py +54 -54
  38. edsl/coop/__init__.py +2 -2
  39. edsl/coop/coop.py +1106 -1028
  40. edsl/coop/utils.py +131 -131
  41. edsl/data/Cache.py +573 -555
  42. edsl/data/CacheEntry.py +230 -233
  43. edsl/data/CacheHandler.py +168 -149
  44. edsl/data/RemoteCacheSync.py +186 -78
  45. edsl/data/SQLiteDict.py +292 -292
  46. edsl/data/__init__.py +5 -4
  47. edsl/data/orm.py +10 -10
  48. edsl/data_transfer_models.py +74 -73
  49. edsl/enums.py +202 -175
  50. edsl/exceptions/BaseException.py +21 -21
  51. edsl/exceptions/__init__.py +54 -54
  52. edsl/exceptions/agents.py +54 -42
  53. edsl/exceptions/cache.py +5 -5
  54. edsl/exceptions/configuration.py +16 -16
  55. edsl/exceptions/coop.py +10 -10
  56. edsl/exceptions/data.py +14 -14
  57. edsl/exceptions/general.py +34 -34
  58. edsl/exceptions/inference_services.py +5 -0
  59. edsl/exceptions/jobs.py +33 -33
  60. edsl/exceptions/language_models.py +63 -63
  61. edsl/exceptions/prompts.py +15 -15
  62. edsl/exceptions/questions.py +109 -91
  63. edsl/exceptions/results.py +29 -29
  64. edsl/exceptions/scenarios.py +29 -22
  65. edsl/exceptions/surveys.py +37 -37
  66. edsl/inference_services/AnthropicService.py +106 -87
  67. edsl/inference_services/AvailableModelCacheHandler.py +184 -0
  68. edsl/inference_services/AvailableModelFetcher.py +215 -0
  69. edsl/inference_services/AwsBedrock.py +118 -120
  70. edsl/inference_services/AzureAI.py +215 -217
  71. edsl/inference_services/DeepInfraService.py +18 -18
  72. edsl/inference_services/GoogleService.py +143 -148
  73. edsl/inference_services/GroqService.py +20 -20
  74. edsl/inference_services/InferenceServiceABC.py +80 -147
  75. edsl/inference_services/InferenceServicesCollection.py +138 -97
  76. edsl/inference_services/MistralAIService.py +120 -123
  77. edsl/inference_services/OllamaService.py +18 -18
  78. edsl/inference_services/OpenAIService.py +236 -224
  79. edsl/inference_services/PerplexityService.py +160 -163
  80. edsl/inference_services/ServiceAvailability.py +135 -0
  81. edsl/inference_services/TestService.py +90 -89
  82. edsl/inference_services/TogetherAIService.py +172 -170
  83. edsl/inference_services/data_structures.py +134 -0
  84. edsl/inference_services/models_available_cache.py +118 -118
  85. edsl/inference_services/rate_limits_cache.py +25 -25
  86. edsl/inference_services/registry.py +41 -41
  87. edsl/inference_services/write_available.py +10 -10
  88. edsl/jobs/AnswerQuestionFunctionConstructor.py +223 -0
  89. edsl/jobs/Answers.py +43 -56
  90. edsl/jobs/FetchInvigilator.py +47 -0
  91. edsl/jobs/InterviewTaskManager.py +98 -0
  92. edsl/jobs/InterviewsConstructor.py +50 -0
  93. edsl/jobs/Jobs.py +823 -898
  94. edsl/jobs/JobsChecks.py +172 -147
  95. edsl/jobs/JobsComponentConstructor.py +189 -0
  96. edsl/jobs/JobsPrompts.py +270 -268
  97. edsl/jobs/JobsRemoteInferenceHandler.py +311 -239
  98. edsl/jobs/JobsRemoteInferenceLogger.py +239 -0
  99. edsl/jobs/RequestTokenEstimator.py +30 -0
  100. edsl/jobs/__init__.py +1 -1
  101. edsl/jobs/async_interview_runner.py +138 -0
  102. edsl/jobs/buckets/BucketCollection.py +104 -63
  103. edsl/jobs/buckets/ModelBuckets.py +65 -65
  104. edsl/jobs/buckets/TokenBucket.py +283 -251
  105. edsl/jobs/buckets/TokenBucketAPI.py +211 -0
  106. edsl/jobs/buckets/TokenBucketClient.py +191 -0
  107. edsl/jobs/check_survey_scenario_compatibility.py +85 -0
  108. edsl/jobs/data_structures.py +120 -0
  109. edsl/jobs/decorators.py +35 -0
  110. edsl/jobs/interviews/Interview.py +396 -661
  111. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  112. edsl/jobs/interviews/InterviewExceptionEntry.py +186 -186
  113. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  114. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  115. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  116. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  117. edsl/jobs/interviews/ReportErrors.py +66 -66
  118. edsl/jobs/interviews/interview_status_enum.py +9 -9
  119. edsl/jobs/jobs_status_enums.py +9 -0
  120. edsl/jobs/loggers/HTMLTableJobLogger.py +304 -0
  121. edsl/jobs/results_exceptions_handler.py +98 -0
  122. edsl/jobs/runners/JobsRunnerAsyncio.py +151 -466
  123. edsl/jobs/runners/JobsRunnerStatus.py +297 -330
  124. edsl/jobs/tasks/QuestionTaskCreator.py +244 -242
  125. edsl/jobs/tasks/TaskCreators.py +64 -64
  126. edsl/jobs/tasks/TaskHistory.py +470 -450
  127. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  128. edsl/jobs/tasks/task_status_enum.py +161 -163
  129. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  130. edsl/jobs/tokens/TokenUsage.py +34 -34
  131. edsl/language_models/ComputeCost.py +63 -0
  132. edsl/language_models/LanguageModel.py +626 -668
  133. edsl/language_models/ModelList.py +164 -155
  134. edsl/language_models/PriceManager.py +127 -0
  135. edsl/language_models/RawResponseHandler.py +106 -0
  136. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  137. edsl/language_models/ServiceDataSources.py +0 -0
  138. edsl/language_models/__init__.py +2 -3
  139. edsl/language_models/fake_openai_call.py +15 -15
  140. edsl/language_models/fake_openai_service.py +61 -61
  141. edsl/language_models/key_management/KeyLookup.py +63 -0
  142. edsl/language_models/key_management/KeyLookupBuilder.py +273 -0
  143. edsl/language_models/key_management/KeyLookupCollection.py +38 -0
  144. edsl/language_models/key_management/__init__.py +0 -0
  145. edsl/language_models/key_management/models.py +131 -0
  146. edsl/language_models/model.py +256 -0
  147. edsl/language_models/repair.py +156 -156
  148. edsl/language_models/utilities.py +65 -64
  149. edsl/notebooks/Notebook.py +263 -258
  150. edsl/notebooks/NotebookToLaTeX.py +142 -0
  151. edsl/notebooks/__init__.py +1 -1
  152. edsl/prompts/Prompt.py +352 -362
  153. edsl/prompts/__init__.py +2 -2
  154. edsl/questions/ExceptionExplainer.py +77 -0
  155. edsl/questions/HTMLQuestion.py +103 -0
  156. edsl/questions/QuestionBase.py +518 -664
  157. edsl/questions/QuestionBasePromptsMixin.py +221 -217
  158. edsl/questions/QuestionBudget.py +227 -227
  159. edsl/questions/QuestionCheckBox.py +359 -359
  160. edsl/questions/QuestionExtract.py +180 -182
  161. edsl/questions/QuestionFreeText.py +113 -114
  162. edsl/questions/QuestionFunctional.py +166 -166
  163. edsl/questions/QuestionList.py +223 -231
  164. edsl/questions/QuestionMatrix.py +265 -0
  165. edsl/questions/QuestionMultipleChoice.py +330 -286
  166. edsl/questions/QuestionNumerical.py +151 -153
  167. edsl/questions/QuestionRank.py +314 -324
  168. edsl/questions/Quick.py +41 -41
  169. edsl/questions/SimpleAskMixin.py +74 -73
  170. edsl/questions/__init__.py +27 -26
  171. edsl/questions/{AnswerValidatorMixin.py → answer_validator_mixin.py} +334 -289
  172. edsl/questions/compose_questions.py +98 -98
  173. edsl/questions/data_structures.py +20 -0
  174. edsl/questions/decorators.py +21 -21
  175. edsl/questions/derived/QuestionLikertFive.py +76 -76
  176. edsl/questions/derived/QuestionLinearScale.py +90 -87
  177. edsl/questions/derived/QuestionTopK.py +93 -93
  178. edsl/questions/derived/QuestionYesNo.py +82 -82
  179. edsl/questions/descriptors.py +427 -413
  180. edsl/questions/loop_processor.py +149 -0
  181. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  182. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  183. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  184. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  185. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  186. edsl/questions/prompt_templates/question_list.jinja +17 -17
  187. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  188. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  189. edsl/questions/{QuestionBaseGenMixin.py → question_base_gen_mixin.py} +168 -161
  190. edsl/questions/question_registry.py +177 -177
  191. edsl/questions/{RegisterQuestionsMeta.py → register_questions_meta.py} +71 -71
  192. edsl/questions/{ResponseValidatorABC.py → response_validator_abc.py} +188 -174
  193. edsl/questions/response_validator_factory.py +34 -0
  194. edsl/questions/settings.py +12 -12
  195. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  196. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  197. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  198. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  199. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  200. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  201. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  202. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  203. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  204. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  205. edsl/questions/templates/list/question_presentation.jinja +5 -5
  206. edsl/questions/templates/matrix/__init__.py +1 -0
  207. edsl/questions/templates/matrix/answering_instructions.jinja +5 -0
  208. edsl/questions/templates/matrix/question_presentation.jinja +20 -0
  209. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  210. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  211. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  212. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  213. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  214. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  215. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  216. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  217. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  218. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  219. edsl/results/CSSParameterizer.py +108 -108
  220. edsl/results/Dataset.py +587 -424
  221. edsl/results/DatasetExportMixin.py +594 -731
  222. edsl/results/DatasetTree.py +295 -275
  223. edsl/results/MarkdownToDocx.py +122 -0
  224. edsl/results/MarkdownToPDF.py +111 -0
  225. edsl/results/Result.py +557 -465
  226. edsl/results/Results.py +1183 -1165
  227. edsl/results/ResultsExportMixin.py +45 -43
  228. edsl/results/ResultsGGMixin.py +121 -121
  229. edsl/results/TableDisplay.py +125 -198
  230. edsl/results/TextEditor.py +50 -0
  231. edsl/results/__init__.py +2 -2
  232. edsl/results/file_exports.py +252 -0
  233. edsl/results/{ResultsFetchMixin.py → results_fetch_mixin.py} +33 -33
  234. edsl/results/{Selector.py → results_selector.py} +145 -135
  235. edsl/results/{ResultsToolsMixin.py → results_tools_mixin.py} +98 -98
  236. edsl/results/smart_objects.py +96 -0
  237. edsl/results/table_data_class.py +12 -0
  238. edsl/results/table_display.css +77 -77
  239. edsl/results/table_renderers.py +118 -0
  240. edsl/results/tree_explore.py +115 -115
  241. edsl/scenarios/ConstructDownloadLink.py +109 -0
  242. edsl/scenarios/DocumentChunker.py +102 -0
  243. edsl/scenarios/DocxScenario.py +16 -0
  244. edsl/scenarios/FileStore.py +511 -632
  245. edsl/scenarios/PdfExtractor.py +40 -0
  246. edsl/scenarios/Scenario.py +498 -601
  247. edsl/scenarios/ScenarioHtmlMixin.py +65 -64
  248. edsl/scenarios/ScenarioList.py +1458 -1287
  249. edsl/scenarios/ScenarioListExportMixin.py +45 -52
  250. edsl/scenarios/ScenarioListPdfMixin.py +239 -261
  251. edsl/scenarios/__init__.py +3 -4
  252. edsl/scenarios/directory_scanner.py +96 -0
  253. edsl/scenarios/file_methods.py +85 -0
  254. edsl/scenarios/handlers/__init__.py +13 -0
  255. edsl/scenarios/handlers/csv.py +38 -0
  256. edsl/scenarios/handlers/docx.py +76 -0
  257. edsl/scenarios/handlers/html.py +37 -0
  258. edsl/scenarios/handlers/json.py +111 -0
  259. edsl/scenarios/handlers/latex.py +5 -0
  260. edsl/scenarios/handlers/md.py +51 -0
  261. edsl/scenarios/handlers/pdf.py +68 -0
  262. edsl/scenarios/handlers/png.py +39 -0
  263. edsl/scenarios/handlers/pptx.py +105 -0
  264. edsl/scenarios/handlers/py.py +294 -0
  265. edsl/scenarios/handlers/sql.py +313 -0
  266. edsl/scenarios/handlers/sqlite.py +149 -0
  267. edsl/scenarios/handlers/txt.py +33 -0
  268. edsl/scenarios/{ScenarioJoin.py → scenario_join.py} +131 -127
  269. edsl/scenarios/scenario_selector.py +156 -0
  270. edsl/shared.py +1 -1
  271. edsl/study/ObjectEntry.py +173 -173
  272. edsl/study/ProofOfWork.py +113 -113
  273. edsl/study/SnapShot.py +80 -80
  274. edsl/study/Study.py +521 -528
  275. edsl/study/__init__.py +4 -4
  276. edsl/surveys/ConstructDAG.py +92 -0
  277. edsl/surveys/DAG.py +148 -148
  278. edsl/surveys/EditSurvey.py +221 -0
  279. edsl/surveys/InstructionHandler.py +100 -0
  280. edsl/surveys/Memory.py +31 -31
  281. edsl/surveys/MemoryManagement.py +72 -0
  282. edsl/surveys/MemoryPlan.py +244 -244
  283. edsl/surveys/Rule.py +327 -326
  284. edsl/surveys/RuleCollection.py +385 -387
  285. edsl/surveys/RuleManager.py +172 -0
  286. edsl/surveys/Simulator.py +75 -0
  287. edsl/surveys/Survey.py +1280 -1801
  288. edsl/surveys/SurveyCSS.py +273 -261
  289. edsl/surveys/SurveyExportMixin.py +259 -259
  290. edsl/surveys/{SurveyFlowVisualizationMixin.py → SurveyFlowVisualization.py} +181 -179
  291. edsl/surveys/SurveyQualtricsImport.py +284 -284
  292. edsl/surveys/SurveyToApp.py +141 -0
  293. edsl/surveys/__init__.py +5 -3
  294. edsl/surveys/base.py +53 -53
  295. edsl/surveys/descriptors.py +60 -56
  296. edsl/surveys/instructions/ChangeInstruction.py +48 -49
  297. edsl/surveys/instructions/Instruction.py +56 -65
  298. edsl/surveys/instructions/InstructionCollection.py +82 -77
  299. edsl/templates/error_reporting/base.html +23 -23
  300. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  301. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  302. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  303. edsl/templates/error_reporting/interview_details.html +115 -115
  304. edsl/templates/error_reporting/interviews.html +19 -19
  305. edsl/templates/error_reporting/overview.html +4 -4
  306. edsl/templates/error_reporting/performance_plot.html +1 -1
  307. edsl/templates/error_reporting/report.css +73 -73
  308. edsl/templates/error_reporting/report.html +117 -117
  309. edsl/templates/error_reporting/report.js +25 -25
  310. edsl/tools/__init__.py +1 -1
  311. edsl/tools/clusters.py +192 -192
  312. edsl/tools/embeddings.py +27 -27
  313. edsl/tools/embeddings_plotting.py +118 -118
  314. edsl/tools/plotting.py +112 -112
  315. edsl/tools/summarize.py +18 -18
  316. edsl/utilities/PrettyList.py +56 -0
  317. edsl/utilities/SystemInfo.py +28 -28
  318. edsl/utilities/__init__.py +22 -22
  319. edsl/utilities/ast_utilities.py +25 -25
  320. edsl/utilities/data/Registry.py +6 -6
  321. edsl/utilities/data/__init__.py +1 -1
  322. edsl/utilities/data/scooter_results.json +1 -1
  323. edsl/utilities/decorators.py +77 -77
  324. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  325. edsl/utilities/interface.py +627 -627
  326. edsl/utilities/is_notebook.py +18 -0
  327. edsl/utilities/is_valid_variable_name.py +11 -0
  328. edsl/utilities/naming_utilities.py +263 -263
  329. edsl/utilities/remove_edsl_version.py +24 -0
  330. edsl/utilities/repair_functions.py +28 -28
  331. edsl/utilities/restricted_python.py +70 -70
  332. edsl/utilities/utilities.py +436 -424
  333. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev5.dist-info}/LICENSE +21 -21
  334. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev5.dist-info}/METADATA +13 -11
  335. edsl-0.1.39.dev5.dist-info/RECORD +358 -0
  336. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev5.dist-info}/WHEEL +1 -1
  337. edsl/language_models/KeyLookup.py +0 -30
  338. edsl/language_models/registry.py +0 -190
  339. edsl/language_models/unused/ReplicateBase.py +0 -83
  340. edsl/results/ResultsDBMixin.py +0 -238
  341. edsl-0.1.39.dev3.dist-info/RECORD +0 -277
@@ -1,387 +1,385 @@
1
- """A collection of rules for a survey."""
2
-
3
- from typing import List, Union, Any, Optional
4
- from collections import defaultdict, UserList
5
-
6
- from edsl.exceptions import (
7
- SurveyRuleCannotEvaluateError,
8
- SurveyRuleCollectionHasNoRulesAtNodeError,
9
- )
10
- from edsl.utilities.interface import print_table_with_rich
11
- from edsl.surveys.Rule import Rule
12
- from edsl.surveys.base import EndOfSurvey
13
- from edsl.surveys.DAG import DAG
14
-
15
- # from graphlib import TopologicalSorter
16
-
17
- from collections import namedtuple
18
-
19
- NextQuestion = namedtuple(
20
- "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
21
- )
22
-
23
-
24
- class RuleCollection(UserList):
25
- """A collection of rules for a particular survey."""
26
-
27
- def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
28
- """Initialize the RuleCollection object.
29
-
30
- :param num_questions: The number of questions in the survey.
31
- :param rules: A list of Rule objects.
32
- """
33
- super().__init__(rules or [])
34
- self.num_questions = num_questions
35
-
36
- def __repr__(self):
37
- """Return a string representation of the RuleCollection object.
38
-
39
- Example usage:
40
-
41
- .. code-block:: python
42
-
43
- rule_collection = RuleCollection.example()
44
- _ = eval(repr(rule_collection))
45
-
46
- """
47
- return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
48
-
49
- def to_dict(self, add_edsl_version=True):
50
- """Create a dictionary representation of the RuleCollection object."""
51
- return {
52
- "rules": [rule.to_dict() for rule in self],
53
- "num_questions": self.num_questions,
54
- }
55
-
56
- @classmethod
57
- def from_dict(cls, rule_collection_dict):
58
- """Create a RuleCollection object from a dictionary.
59
-
60
- >>> rule_collection = RuleCollection.example()
61
- >>> rule_collection_dict = rule_collection.to_dict()
62
- >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
63
- >>> repr(new_rule_collection) == repr(rule_collection)
64
- True
65
- """
66
- rules = [
67
- Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
68
- ]
69
- num_questions = rule_collection_dict["num_questions"]
70
- new_rc = cls(rules=rules)
71
- new_rc.num_questions = num_questions
72
- return new_rc
73
-
74
- def add_rule(self, rule: Rule) -> None:
75
- """Add a rule to a survey.
76
-
77
- >>> rule_collection = RuleCollection()
78
- >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
79
- >>> len(rule_collection)
80
- 1
81
-
82
- >>> rule_collection = RuleCollection()
83
- >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
84
- >>> rule_collection.add_rule(r)
85
- >>> rule_collection[0] == r
86
- True
87
- >>> len(rule_collection.applicable_rules(1, before_rule=True))
88
- 1
89
- >>> len(rule_collection.applicable_rules(1, before_rule=False))
90
- 0
91
- """
92
- self.append(rule)
93
-
94
- def show_rules(self) -> None:
95
- """Print the rules in a table.
96
-
97
-
98
- .. code-block:: python
99
-
100
- rule_collection = RuleCollection.example()
101
- rule_collection.show_rules()
102
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
103
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
104
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
105
- │ 1 │ q1 == 'yes' │ 3 │ 1 │ False │
106
- │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
107
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
108
- """
109
- keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
110
- rule_list = []
111
- for rule in sorted(self, key=lambda r: r.current_q):
112
- rule_list.append({k: getattr(rule, k) for k in keys})
113
-
114
- print_table_with_rich(rule_list)
115
-
116
- def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
117
- """Determine if a question should be skipped before running the question.
118
-
119
- :param q_now: The current question index.
120
- :param answers: The answers to the survey questions.
121
-
122
- >>> rule_collection = RuleCollection()
123
- >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
124
- >>> rule_collection.add_rule(r)
125
- >>> rule_collection.skip_question_before_running(1, {})
126
- True
127
-
128
- >>> rule_collection = RuleCollection()
129
- >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
130
- >>> rule_collection.add_rule(r)
131
- >>> rule_collection.skip_question_before_running(1, {})
132
- False
133
-
134
- """
135
- for rule in self.applicable_rules(q_now, before_rule=True):
136
- if rule.evaluate(answers):
137
- return True
138
- return False
139
-
140
- def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
141
- """Show the rules that apply at the current node.
142
-
143
- :param q_now: The current question index.
144
- :param before_rule: If True, return rules that are of the type that apply before the question is asked.
145
-
146
- Example usage:
147
-
148
- >>> rule_collection = RuleCollection.example()
149
- >>> rule_collection.applicable_rules(1)
150
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
151
-
152
- The default is that the rule is applied after the question is asked.
153
- If we want to see the rules that apply before the question is asked, we can set before_rule=True.
154
-
155
- .. code-block:: python
156
-
157
- rule_collection = RuleCollection.example()
158
- rule_collection.applicable_rules(1)
159
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
160
-
161
- More than one rule can apply. For example, suppose we are at node 1.
162
- We could have three rules:
163
- 1. "q1 == 'a' ==> 3
164
- 2. "q1 == 'b' ==> 4
165
- 3. "q1 == 'c' ==> 5
166
- """
167
- return [
168
- rule
169
- for rule in self
170
- if rule.current_q == q_now and rule.before_rule == before_rule
171
- ]
172
-
173
- def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
174
- """Find the next question by index, given the rule collection.
175
-
176
- This rule is applied after the question is answered.
177
-
178
- :param q_now: The current question index.
179
- :param answers: The answers to the survey questions so far, including the current question.
180
-
181
- >>> rule_collection = RuleCollection.example()
182
- >>> rule_collection.next_question(1, {'q1': 'yes'})
183
- NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
184
-
185
- """
186
- # # is this the first question? If it is, we need to check if it should be skipped.
187
- # if q_now == 0:
188
- # if self.skip_question_before_running(q_now, answers):
189
- # return NextQuestion(
190
- # next_q=q_now + 1,
191
- # num_rules_found=0,
192
- # expressions_evaluating_to_true=0,
193
- # priority=-1,
194
- # )
195
-
196
- # breakpoint()
197
- expressions_evaluating_to_true = 0
198
- next_q = None
199
- highest_priority = -2 # start with -2 to 'pick up' the default rule added
200
- num_rules_found = 0
201
-
202
- for rule in self.applicable_rules(q_now, before_rule=False):
203
- num_rules_found += 1
204
- try:
205
- if rule.evaluate(answers): # evaluates to True
206
- expressions_evaluating_to_true += 1
207
- if rule.priority > highest_priority: # higher priority
208
- # we have a new champ!
209
- next_q, highest_priority = rule.next_q, rule.priority
210
- except SurveyRuleCannotEvaluateError:
211
- raise
212
-
213
- if num_rules_found == 0:
214
- raise SurveyRuleCollectionHasNoRulesAtNodeError(
215
- f"No rules found for question {q_now}"
216
- )
217
-
218
- # breakpoint()
219
- ## Now we need to check if the *next question* has any 'before; rules that we should follow
220
- for rule in self.applicable_rules(next_q, before_rule=True):
221
- if rule.evaluate(answers): # rule evaluates to True
222
- return self.next_question(next_q, answers)
223
-
224
- return NextQuestion(
225
- next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
226
- )
227
-
228
- @property
229
- def non_default_rules(self) -> List[Rule]:
230
- """Return all rules that are not the default rule.
231
-
232
- >>> rule_collection = RuleCollection.example()
233
- >>> len(rule_collection.non_default_rules)
234
- 2
235
-
236
- Example usage:
237
-
238
- .. code-block:: python
239
-
240
- rule_collection = RuleCollection.example()
241
- len(rule_collection.non_default_rules)
242
- 2
243
-
244
- """
245
- return [rule for rule in self if rule.priority > -1]
246
-
247
- def keys_between(self, start_q, end_q, right_inclusive=True):
248
- """Return a list of all question indices between start_q and end_q.
249
-
250
- Example usage:
251
-
252
- .. code-block:: python
253
-
254
- rule_collection = RuleCollection(num_questions=5)
255
- rule_collection.keys_between(1, 3)
256
- [2, 3]
257
- rule_collection.keys_between(1, 4)
258
- [2, 3, 4]
259
- rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
260
- [2, 3]
261
-
262
- """
263
- # If it's the end of the survey, all questions between the start_q and the end of the survey
264
- # now depend on the start_q
265
- if end_q == EndOfSurvey:
266
- if self.num_questions is None:
267
- raise ValueError(
268
- "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
269
- )
270
- end_q = self.num_questions - 1
271
-
272
- question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
273
-
274
- return question_range
275
-
276
- @property
277
- def dag(self) -> dict:
278
- """
279
- Find the DAG of the survey, based on the skip logic.
280
-
281
- Keys are children questions; the list of values are nodes that must be answered first
282
-
283
- Rules are designated at the current question and then direct where
284
- control goes next. As such, the destination nodes are the keys
285
- and the current nodes are the values. Furthermore, all questions between
286
- the current and destination nodes are also included as keys, as they will depend
287
- on the answer to the focal node as well.
288
-
289
- For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
290
- So the DAG would be {3: [1], 2: [1]}.
291
-
292
- Example usage:
293
-
294
- .. code-block:: python
295
-
296
- rule_collection = RuleCollection(num_questions=5)
297
- qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
298
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
299
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
300
- rule_collection.dag
301
- {2: {1}, 3: {1}}
302
-
303
- """
304
- children_to_parents = defaultdict(set)
305
- # We are only interested in non-default rules. Default rules are those
306
- # that just go to the next question, so they don't add any dependencies
307
-
308
- ## I think for a skip-question, the potenially-skippable question
309
- ## depends on all the other questions bein answered first.
310
- for rule in self.non_default_rules:
311
- if not rule.before_rule:
312
- # for a regular rule, the next question depends on the current question answer
313
- current_q, next_q = rule.current_q, rule.next_q
314
- for q in self.keys_between(current_q, next_q):
315
- children_to_parents[q].add(current_q)
316
- else:
317
- # for the 'before rule' skipping depends on all previous answers.
318
- focal_q = rule.current_q
319
- for q in range(0, focal_q):
320
- children_to_parents[focal_q].add(q)
321
-
322
- return DAG(dict(sorted(children_to_parents.items())))
323
-
324
- def detect_cycles(self):
325
- """
326
- Detect cycles in the survey rules using depth-first search.
327
-
328
- :return: A list of cycles if any are found, otherwise an empty list.
329
- """
330
- dag = self.dag
331
- visited = set()
332
- path = []
333
- cycles = []
334
-
335
- def dfs(node):
336
- if node in path:
337
- cycle = path[path.index(node) :]
338
- cycles.append(cycle + [node])
339
- return
340
-
341
- if node in visited:
342
- return
343
-
344
- visited.add(node)
345
- path.append(node)
346
-
347
- for child in dag.get(node, []):
348
- dfs(child)
349
-
350
- path.pop()
351
-
352
- for node in dag:
353
- if node not in visited:
354
- dfs(node)
355
-
356
- return cycles
357
-
358
- @classmethod
359
- def example(cls):
360
- """Create an example RuleCollection object."""
361
- qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
362
- return cls(
363
- num_questions=5,
364
- rules=[
365
- Rule(
366
- current_q=1,
367
- expression="q1 == 'yes'",
368
- next_q=3,
369
- priority=1,
370
- question_name_to_index=qn2i,
371
- ),
372
- Rule(
373
- current_q=1,
374
- expression="q1 == 'no'",
375
- next_q=2,
376
- priority=1,
377
- question_name_to_index=qn2i,
378
- ),
379
- ],
380
- )
381
-
382
-
383
- if __name__ == "__main__":
384
- # pass
385
- import doctest
386
-
387
- doctest.testmod(optionflags=doctest.ELLIPSIS)
1
+ """A collection of rules for a survey."""
2
+
3
+ from typing import List, Union, Any, Optional
4
+ from collections import defaultdict, UserList, namedtuple
5
+
6
+ from edsl.exceptions.surveys import (
7
+ SurveyRuleCannotEvaluateError,
8
+ SurveyRuleCollectionHasNoRulesAtNodeError,
9
+ )
10
+
11
+ from edsl.surveys.Rule import Rule
12
+ from edsl.surveys.base import EndOfSurvey
13
+ from edsl.surveys.DAG import DAG
14
+
15
+
16
+ NextQuestion = namedtuple(
17
+ "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
18
+ )
19
+
20
+
21
+ class RuleCollection(UserList):
22
+ """A collection of rules for a particular survey."""
23
+
24
+ def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
25
+ """Initialize the RuleCollection object.
26
+
27
+ :param num_questions: The number of questions in the survey.
28
+ :param rules: A list of Rule objects.
29
+ """
30
+ super().__init__(rules or [])
31
+ self.num_questions = num_questions
32
+
33
+ def __repr__(self):
34
+ """Return a string representation of the RuleCollection object.
35
+
36
+ Example usage:
37
+
38
+ .. code-block:: python
39
+
40
+ rule_collection = RuleCollection.example()
41
+ _ = eval(repr(rule_collection))
42
+
43
+ """
44
+ return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
45
+
46
+ def to_dataset(self):
47
+ """Return a Dataset object representation of the RuleCollection object."""
48
+ from edsl.results.Dataset import Dataset
49
+
50
+ keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
51
+ rule_list = {}
52
+ for rule in sorted(self, key=lambda r: r.current_q):
53
+ for k in keys:
54
+ rule_list.setdefault(k, []).append(getattr(rule, k))
55
+
56
+ return Dataset([{k: v} for k, v in rule_list.items()])
57
+
58
+ def _repr_html_(self):
59
+ """Return an HTML representation of the RuleCollection object."""
60
+ from edsl.results.Dataset import Dataset
61
+
62
+ return self.to_dataset()._repr_html_()
63
+
64
+ def to_dict(self, add_edsl_version=True):
65
+ """Create a dictionary representation of the RuleCollection object."""
66
+ return {
67
+ "rules": [rule.to_dict() for rule in self],
68
+ "num_questions": self.num_questions,
69
+ }
70
+
71
+ @classmethod
72
+ def from_dict(cls, rule_collection_dict):
73
+ """Create a RuleCollection object from a dictionary.
74
+
75
+ >>> rule_collection = RuleCollection.example()
76
+ >>> rule_collection_dict = rule_collection.to_dict()
77
+ >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
78
+ >>> repr(new_rule_collection) == repr(rule_collection)
79
+ True
80
+ """
81
+ rules = [
82
+ Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
83
+ ]
84
+ num_questions = rule_collection_dict["num_questions"]
85
+ new_rc = cls(rules=rules)
86
+ new_rc.num_questions = num_questions
87
+ return new_rc
88
+
89
+ def add_rule(self, rule: Rule) -> None:
90
+ """Add a rule to a survey.
91
+
92
+ >>> rule_collection = RuleCollection()
93
+ >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
94
+ >>> len(rule_collection)
95
+ 1
96
+
97
+ >>> rule_collection = RuleCollection()
98
+ >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
99
+ >>> rule_collection.add_rule(r)
100
+ >>> rule_collection[0] == r
101
+ True
102
+ >>> len(rule_collection.applicable_rules(1, before_rule=True))
103
+ 1
104
+ >>> len(rule_collection.applicable_rules(1, before_rule=False))
105
+ 0
106
+ """
107
+ self.append(rule)
108
+
109
+ def show_rules(self) -> None:
110
+ """Print the rules in a table.
111
+
112
+
113
+ .. code-block:: python
114
+
115
+ rule_collection = RuleCollection.example()
116
+ rule_collection.show_rules()
117
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
118
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
119
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
120
+ 1 │ q1 == 'yes' 3 │ 1 │ False │
121
+ │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
122
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
123
+ """
124
+ return self.to_dataset()
125
+
126
+ def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
127
+ """Determine if a question should be skipped before running the question.
128
+
129
+ :param q_now: The current question index.
130
+ :param answers: The answers to the survey questions.
131
+
132
+ >>> rule_collection = RuleCollection()
133
+ >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
134
+ >>> rule_collection.add_rule(r)
135
+ >>> rule_collection.skip_question_before_running(1, {})
136
+ True
137
+
138
+ >>> rule_collection = RuleCollection()
139
+ >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
140
+ >>> rule_collection.add_rule(r)
141
+ >>> rule_collection.skip_question_before_running(1, {})
142
+ False
143
+
144
+ """
145
+ for rule in self.applicable_rules(q_now, before_rule=True):
146
+ if rule.evaluate(answers):
147
+ return True
148
+ return False
149
+
150
+ def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
151
+ """Show the rules that apply at the current node.
152
+
153
+ :param q_now: The current question index.
154
+ :param before_rule: If True, return rules that are of the type that apply before the question is asked.
155
+
156
+ Example usage:
157
+
158
+ >>> rule_collection = RuleCollection.example()
159
+ >>> rule_collection.applicable_rules(1)
160
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
161
+
162
+ The default is that the rule is applied after the question is asked.
163
+ If we want to see the rules that apply before the question is asked, we can set before_rule=True.
164
+
165
+ .. code-block:: python
166
+
167
+ rule_collection = RuleCollection.example()
168
+ rule_collection.applicable_rules(1)
169
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
170
+
171
+ More than one rule can apply. For example, suppose we are at node 1.
172
+ We could have three rules:
173
+ 1. "q1 == 'a' ==> 3
174
+ 2. "q1 == 'b' ==> 4
175
+ 3. "q1 == 'c' ==> 5
176
+ """
177
+ return [
178
+ rule
179
+ for rule in self
180
+ if rule.current_q == q_now and rule.before_rule == before_rule
181
+ ]
182
+
183
+ def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
184
+ """Find the next question by index, given the rule collection.
185
+
186
+ This rule is applied after the question is answered.
187
+
188
+ :param q_now: The current question index.
189
+ :param answers: The answers to the survey questions so far, including the current question.
190
+
191
+ >>> rule_collection = RuleCollection.example()
192
+ >>> rule_collection.next_question(1, {'q1': 'yes'})
193
+ NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
194
+
195
+ """
196
+ expressions_evaluating_to_true = 0
197
+ next_q = None
198
+ highest_priority = -2 # start with -2 to 'pick up' the default rule added
199
+ num_rules_found = 0
200
+
201
+ for rule in self.applicable_rules(q_now, before_rule=False):
202
+ num_rules_found += 1
203
+ try:
204
+ if rule.evaluate(answers): # evaluates to True
205
+ expressions_evaluating_to_true += 1
206
+ if rule.priority > highest_priority: # higher priority
207
+ # we have a new champ!
208
+ next_q, highest_priority = rule.next_q, rule.priority
209
+ except SurveyRuleCannotEvaluateError:
210
+ raise
211
+
212
+ if num_rules_found == 0:
213
+ raise SurveyRuleCollectionHasNoRulesAtNodeError(
214
+ f"No rules found for question {q_now}"
215
+ )
216
+
217
+ ## Now we need to check if the *next question* has any 'before; rules that we should follow
218
+ for rule in self.applicable_rules(next_q, before_rule=True):
219
+ if rule.evaluate(answers): # rule evaluates to True
220
+ return self.next_question(next_q, answers)
221
+
222
+ return NextQuestion(
223
+ next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
224
+ )
225
+
226
+ @property
227
+ def non_default_rules(self) -> List[Rule]:
228
+ """Return all rules that are not the default rule.
229
+
230
+ >>> rule_collection = RuleCollection.example()
231
+ >>> len(rule_collection.non_default_rules)
232
+ 2
233
+
234
+ Example usage:
235
+
236
+ .. code-block:: python
237
+
238
+ rule_collection = RuleCollection.example()
239
+ len(rule_collection.non_default_rules)
240
+ 2
241
+
242
+ """
243
+ return [rule for rule in self if rule.priority > -1]
244
+
245
+ def keys_between(self, start_q, end_q, right_inclusive=True):
246
+ """Return a list of all question indices between start_q and end_q.
247
+
248
+ Example usage:
249
+
250
+ .. code-block:: python
251
+
252
+ rule_collection = RuleCollection(num_questions=5)
253
+ rule_collection.keys_between(1, 3)
254
+ [2, 3]
255
+ rule_collection.keys_between(1, 4)
256
+ [2, 3, 4]
257
+ rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
258
+ [2, 3]
259
+
260
+ """
261
+ # If it's the end of the survey, all questions between the start_q and the end of the survey now depend on the start_q
262
+ if end_q == EndOfSurvey:
263
+ if self.num_questions is None:
264
+ raise ValueError(
265
+ "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
266
+ )
267
+ end_q = self.num_questions - 1
268
+
269
+ question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
270
+
271
+ return question_range
272
+
273
+ @property
274
+ def dag(self) -> dict:
275
+ """
276
+ Find the DAG of the survey, based on the skip logic.
277
+
278
+ Keys are children questions; the list of values are nodes that must be answered first
279
+
280
+ Rules are designated at the current question and then direct where
281
+ control goes next. As such, the destination nodes are the keys
282
+ and the current nodes are the values. Furthermore, all questions between
283
+ the current and destination nodes are also included as keys, as they will depend
284
+ on the answer to the focal node as well.
285
+
286
+ For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
287
+ So the DAG would be {3: [1], 2: [1]}.
288
+
289
+ Example usage:
290
+
291
+ .. code-block:: python
292
+
293
+ rule_collection = RuleCollection(num_questions=5)
294
+ qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
295
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
296
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
297
+ rule_collection.dag
298
+ {2: {1}, 3: {1}}
299
+
300
+ """
301
+ children_to_parents = defaultdict(set)
302
+ # We are only interested in non-default rules. Default rules are those
303
+ # that just go to the next question, so they don't add any dependencies
304
+
305
+ ## I think for a skip-question, the potenially-skippable question
306
+ ## depends on all the other questions bein answered first.
307
+ for rule in self.non_default_rules:
308
+ if not rule.before_rule:
309
+ # for a regular rule, the next question depends on the current question answer
310
+ current_q, next_q = rule.current_q, rule.next_q
311
+ for q in self.keys_between(current_q, next_q):
312
+ children_to_parents[q].add(current_q)
313
+ else:
314
+ # for the 'before rule' skipping depends on all previous answers.
315
+ focal_q = rule.current_q
316
+ for q in range(0, focal_q):
317
+ children_to_parents[focal_q].add(q)
318
+
319
+ return DAG(dict(sorted(children_to_parents.items())))
320
+
321
+ def detect_cycles(self):
322
+ """
323
+ Detect cycles in the survey rules using depth-first search.
324
+
325
+ :return: A list of cycles if any are found, otherwise an empty list.
326
+ """
327
+ dag = self.dag
328
+ visited = set()
329
+ path = []
330
+ cycles = []
331
+
332
+ def dfs(node):
333
+ if node in path:
334
+ cycle = path[path.index(node) :]
335
+ cycles.append(cycle + [node])
336
+ return
337
+
338
+ if node in visited:
339
+ return
340
+
341
+ visited.add(node)
342
+ path.append(node)
343
+
344
+ for child in dag.get(node, []):
345
+ dfs(child)
346
+
347
+ path.pop()
348
+
349
+ for node in dag:
350
+ if node not in visited:
351
+ dfs(node)
352
+
353
+ return cycles
354
+
355
+ @classmethod
356
+ def example(cls):
357
+ """Create an example RuleCollection object."""
358
+ qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
359
+ return cls(
360
+ num_questions=5,
361
+ rules=[
362
+ Rule(
363
+ current_q=1,
364
+ expression="q1 == 'yes'",
365
+ next_q=3,
366
+ priority=1,
367
+ question_name_to_index=qn2i,
368
+ ),
369
+ Rule(
370
+ current_q=1,
371
+ expression="q1 == 'no'",
372
+ next_q=2,
373
+ priority=1,
374
+ question_name_to_index=qn2i,
375
+ ),
376
+ ],
377
+ )
378
+
379
+
380
+ if __name__ == "__main__":
381
+ import doctest
382
+
383
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
384
+
385
+ print(RuleCollection.example()._repr_html_())